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,3008 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTrack Issues API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from youtrack_mcp.api.client import YouTrackClient, YouTrackAPIError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttachmentNotFoundError(ValueError):
|
|
18
|
+
"""Raised when an attachment is not found in an issue."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Issue(BaseModel):
|
|
24
|
+
"""Model for a YouTrack issue."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
summary: Optional[str] = None
|
|
28
|
+
description: Optional[str] = None
|
|
29
|
+
created: Optional[int] = None
|
|
30
|
+
updated: Optional[int] = None
|
|
31
|
+
project: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
|
32
|
+
reporter: Optional[Dict[str, Any]] = None
|
|
33
|
+
assignee: Optional[Dict[str, Any]] = None
|
|
34
|
+
custom_fields: Optional[List[Dict[str, Any]]] = Field(default_factory=list)
|
|
35
|
+
attachments: Optional[List[Dict[str, Any]]] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
model_config = {
|
|
38
|
+
"extra": "allow", # Allow extra fields from the API
|
|
39
|
+
"populate_by_name": True, # Allow population by field name (helps with aliases)
|
|
40
|
+
"validate_assignment": False, # Less strict validation for attribute assignment
|
|
41
|
+
"protected_namespaces": (), # Don't protect any namespaces
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def model_validate(cls, obj, *args, **kwargs):
|
|
46
|
+
"""Override model_validate to handle various input formats."""
|
|
47
|
+
try:
|
|
48
|
+
# Try standard validation
|
|
49
|
+
return super().model_validate(obj, *args, **kwargs)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
# Fall back to more permissive validation
|
|
52
|
+
if isinstance(obj, dict):
|
|
53
|
+
# Ensure ID is present
|
|
54
|
+
if "id" not in obj and obj.get("$type") == "Issue":
|
|
55
|
+
# Try to find an ID from other fields or generate placeholder
|
|
56
|
+
obj["id"] = obj.get(
|
|
57
|
+
"idReadable", str(obj.get("created", "unknown-id"))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Create issue with minimal validated data
|
|
61
|
+
return cls(
|
|
62
|
+
id=obj.get("id", "unknown"),
|
|
63
|
+
summary=obj.get("summary", "No summary"),
|
|
64
|
+
description=obj.get("description", ""),
|
|
65
|
+
created=obj.get("created"),
|
|
66
|
+
updated=obj.get("updated"),
|
|
67
|
+
project=obj.get("project", {}),
|
|
68
|
+
reporter=obj.get("reporter"),
|
|
69
|
+
assignee=obj.get("assignee"),
|
|
70
|
+
custom_fields=obj.get("customFields", []),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# If obj isn't even a dict, raise the original error
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class IssuesClient:
|
|
78
|
+
"""Client for interacting with YouTrack Issues API."""
|
|
79
|
+
|
|
80
|
+
# Well-known custom field names that map to version/sprint bundles
|
|
81
|
+
# (matched case-insensitively).
|
|
82
|
+
_VERSION_FIELD_NAMES = [
|
|
83
|
+
'fix versions', 'fix version', 'affected versions', 'affected version',
|
|
84
|
+
'sprints', 'sprint', 'milestone', 'release',
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def __init__(self, client: YouTrackClient):
|
|
88
|
+
"""
|
|
89
|
+
Initialize the Issues API client.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
client: The YouTrack API client
|
|
93
|
+
"""
|
|
94
|
+
self.client = client
|
|
95
|
+
|
|
96
|
+
def get_issue(self, issue_id: str) -> Issue:
|
|
97
|
+
"""
|
|
98
|
+
Get an issue by ID.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
issue_id: The issue ID or readable ID (e.g., PROJECT-123)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The issue data
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
# Get issue data
|
|
108
|
+
response = self.client.get(f"issues/{issue_id}")
|
|
109
|
+
|
|
110
|
+
# If the response doesn't have all needed fields, fetch more details
|
|
111
|
+
if (
|
|
112
|
+
isinstance(response, dict)
|
|
113
|
+
and response.get("$type") == "Issue"
|
|
114
|
+
and "summary" not in response
|
|
115
|
+
):
|
|
116
|
+
# Get additional fields we need including attachments
|
|
117
|
+
fields = "id,idReadable,summary,description,created,updated,project(id,shortName),reporter,assignee,customFields,attachments(id,name,url,mimeType,size)"
|
|
118
|
+
try:
|
|
119
|
+
detailed_response = self.client.get(
|
|
120
|
+
f"issues/{issue_id}?fields={fields}"
|
|
121
|
+
)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning(f"Failed to get detailed issue data: {e}")
|
|
124
|
+
detailed_response = response
|
|
125
|
+
else:
|
|
126
|
+
detailed_response = response
|
|
127
|
+
|
|
128
|
+
# Ensure the ID field is present
|
|
129
|
+
if (
|
|
130
|
+
isinstance(detailed_response, dict)
|
|
131
|
+
and "id" not in detailed_response
|
|
132
|
+
and detailed_response.get("$type") == "Issue"
|
|
133
|
+
):
|
|
134
|
+
detailed_response["id"] = issue_id
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Try to validate the model
|
|
138
|
+
return Issue.model_validate(detailed_response)
|
|
139
|
+
except Exception as validation_error:
|
|
140
|
+
# If validation fails, create a more flexible issue object
|
|
141
|
+
logger.warning(
|
|
142
|
+
f"Issue validation error: {validation_error}. Creating issue with minimal data."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if isinstance(detailed_response, dict):
|
|
146
|
+
# Extract key fields if possible
|
|
147
|
+
summary = detailed_response.get(
|
|
148
|
+
"summary", "Unknown summary"
|
|
149
|
+
)
|
|
150
|
+
description = detailed_response.get("description", "")
|
|
151
|
+
|
|
152
|
+
# Create a basic issue with the available data
|
|
153
|
+
issue = Issue(
|
|
154
|
+
id=issue_id, summary=summary, description=description
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Add any other fields that might be useful
|
|
158
|
+
for field in [
|
|
159
|
+
"created",
|
|
160
|
+
"updated",
|
|
161
|
+
"project",
|
|
162
|
+
"reporter",
|
|
163
|
+
"assignee",
|
|
164
|
+
"attachments",
|
|
165
|
+
]:
|
|
166
|
+
if field in detailed_response:
|
|
167
|
+
setattr(issue, field, detailed_response[field])
|
|
168
|
+
|
|
169
|
+
return issue
|
|
170
|
+
else:
|
|
171
|
+
# If response is not even a dict, create a minimal issue
|
|
172
|
+
return Issue(id=issue_id, summary=f"Issue {issue_id}")
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
# Log the full error with traceback
|
|
176
|
+
logger.exception(f"Error retrieving issue {issue_id}")
|
|
177
|
+
# Create minimal issue to avoid breaking calls
|
|
178
|
+
return Issue(id=issue_id, summary=f"Error: {str(e)[:100]}...")
|
|
179
|
+
|
|
180
|
+
def create_issue(
|
|
181
|
+
self,
|
|
182
|
+
project_id: str,
|
|
183
|
+
summary: str,
|
|
184
|
+
description: Optional[str] = None,
|
|
185
|
+
additional_fields: Optional[Dict[str, Any]] = None,
|
|
186
|
+
) -> Issue:
|
|
187
|
+
"""
|
|
188
|
+
Create a new issue.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
project_id: The ID of the project
|
|
192
|
+
summary: The issue summary
|
|
193
|
+
description: The issue description
|
|
194
|
+
additional_fields: Additional fields to set on the issue
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The created issue data
|
|
198
|
+
"""
|
|
199
|
+
# Make sure we have valid input data
|
|
200
|
+
if not project_id:
|
|
201
|
+
raise ValueError("Project ID is required")
|
|
202
|
+
if not summary:
|
|
203
|
+
raise ValueError("Summary is required")
|
|
204
|
+
|
|
205
|
+
# Format request data according to YouTrack API requirements
|
|
206
|
+
# Note: YouTrack API requires a specific format for some fields
|
|
207
|
+
data = {"project": {"id": project_id}, "summary": summary}
|
|
208
|
+
|
|
209
|
+
if description:
|
|
210
|
+
data["description"] = description
|
|
211
|
+
|
|
212
|
+
if additional_fields:
|
|
213
|
+
data.update(additional_fields)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# For debugging
|
|
217
|
+
logger.info(f"Creating issue with data: {json.dumps(data)}")
|
|
218
|
+
|
|
219
|
+
# Post directly with the json parameter to ensure correct format
|
|
220
|
+
url = "issues"
|
|
221
|
+
response = self.client.session.post(
|
|
222
|
+
f"{self.client.base_url}/{url}",
|
|
223
|
+
json=data,
|
|
224
|
+
headers={
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"Accept": "application/json",
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Handle the response
|
|
231
|
+
if response.status_code >= 400:
|
|
232
|
+
error_msg = f"Error creating issue: {response.status_code}"
|
|
233
|
+
try:
|
|
234
|
+
error_content = response.json()
|
|
235
|
+
error_msg += f" - {json.dumps(error_content)}"
|
|
236
|
+
except Exception:
|
|
237
|
+
error_msg += f" - {response.text}"
|
|
238
|
+
|
|
239
|
+
logger.error(error_msg)
|
|
240
|
+
raise YouTrackAPIError(
|
|
241
|
+
error_msg, response.status_code, response
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Process successful response
|
|
245
|
+
try:
|
|
246
|
+
result = response.json()
|
|
247
|
+
return Issue.model_validate(result)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.error(f"Error parsing response: {str(e)}")
|
|
250
|
+
# Return raw response if we can't parse it
|
|
251
|
+
return Issue(
|
|
252
|
+
id=str(response.status_code),
|
|
253
|
+
summary="Created successfully",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
import logging
|
|
258
|
+
|
|
259
|
+
logging.getLogger(__name__).error(
|
|
260
|
+
f"Error creating issue: {str(e)}, Data: {data}"
|
|
261
|
+
)
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
def update_issue(
|
|
265
|
+
self,
|
|
266
|
+
issue_id: str,
|
|
267
|
+
summary: Optional[str] = None,
|
|
268
|
+
description: Optional[str] = None,
|
|
269
|
+
additional_fields: Optional[Dict[str, Any]] = None,
|
|
270
|
+
) -> Issue:
|
|
271
|
+
"""
|
|
272
|
+
Update an existing issue.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
issue_id: The issue ID or readable ID
|
|
276
|
+
summary: The new issue summary
|
|
277
|
+
description: The new issue description
|
|
278
|
+
additional_fields: Additional fields to update
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The updated issue data
|
|
282
|
+
"""
|
|
283
|
+
data = {}
|
|
284
|
+
|
|
285
|
+
if summary is not None:
|
|
286
|
+
data["summary"] = summary
|
|
287
|
+
|
|
288
|
+
if description is not None:
|
|
289
|
+
data["description"] = description
|
|
290
|
+
|
|
291
|
+
if additional_fields:
|
|
292
|
+
data.update(additional_fields)
|
|
293
|
+
|
|
294
|
+
if not data:
|
|
295
|
+
# Nothing to update
|
|
296
|
+
return self.get_issue(issue_id)
|
|
297
|
+
|
|
298
|
+
response = self.client.post(f"issues/{issue_id}", data=data)
|
|
299
|
+
return Issue.model_validate(response)
|
|
300
|
+
|
|
301
|
+
def update_issue_custom_fields(
|
|
302
|
+
self,
|
|
303
|
+
issue_id: str,
|
|
304
|
+
custom_fields: Dict[str, Any],
|
|
305
|
+
validate: bool = True,
|
|
306
|
+
use_commands: bool = False, # Changed default to False - direct API works better
|
|
307
|
+
) -> Dict[str, Any]:
|
|
308
|
+
"""
|
|
309
|
+
Update multiple custom fields on an issue using proven working approach.
|
|
310
|
+
|
|
311
|
+
Based on successful testing with this YouTrack instance:
|
|
312
|
+
- Direct Field Update API works reliably for state transitions
|
|
313
|
+
- Command API may be blocked by workflow restrictions
|
|
314
|
+
- Uses direct API as primary method with command fallback
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
issue_id: The issue identifier
|
|
318
|
+
custom_fields: Dictionary of field names and values to update
|
|
319
|
+
validate: Whether to validate field values before updating
|
|
320
|
+
use_commands: Whether to try command-based approach as fallback (default: False)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Updated issue data
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
# First, get current issue state to detect state machine workflows
|
|
327
|
+
issue_data = self.get_issue(issue_id)
|
|
328
|
+
|
|
329
|
+
# Check if we're updating State field and if it uses state machines
|
|
330
|
+
state_field_update = None
|
|
331
|
+
other_fields = {}
|
|
332
|
+
|
|
333
|
+
for field_name, field_value in custom_fields.items():
|
|
334
|
+
if field_name.lower() == 'state':
|
|
335
|
+
state_field_update = (field_name, field_value)
|
|
336
|
+
else:
|
|
337
|
+
other_fields[field_name] = field_value
|
|
338
|
+
|
|
339
|
+
# Handle state transitions with enhanced workflow support
|
|
340
|
+
if state_field_update:
|
|
341
|
+
field_name, target_state = state_field_update
|
|
342
|
+
success = self._handle_state_transition(issue_id, target_state, use_commands)
|
|
343
|
+
|
|
344
|
+
# Only try command-based fallback if we haven't already tried it
|
|
345
|
+
if not success and not use_commands:
|
|
346
|
+
logger.info(f"Direct state update failed, trying command-based approach for issue {issue_id}")
|
|
347
|
+
success = self._handle_state_transition(issue_id, target_state, use_commands=True)
|
|
348
|
+
|
|
349
|
+
if not success:
|
|
350
|
+
raise YouTrackAPIError(f"Failed to transition issue {issue_id} to state '{target_state}'. This may be due to workflow restrictions, permissions, or state machine guard conditions.")
|
|
351
|
+
|
|
352
|
+
# Handle other custom fields using existing logic
|
|
353
|
+
if other_fields:
|
|
354
|
+
try:
|
|
355
|
+
self._update_other_custom_fields(issue_id, other_fields, validate, use_commands)
|
|
356
|
+
except YouTrackAPIError as e:
|
|
357
|
+
if "405" in str(e) and not use_commands:
|
|
358
|
+
# 405 Method Not Allowed - try command-based approach
|
|
359
|
+
logger.info(f"Direct API update failed with 405, trying command-based approach for issue {issue_id}")
|
|
360
|
+
self._update_other_custom_fields(issue_id, other_fields, validate, use_commands=True)
|
|
361
|
+
else:
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
# Return updated issue data
|
|
365
|
+
return self.get_issue(issue_id)
|
|
366
|
+
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.exception(f"Error updating custom fields for issue {issue_id}")
|
|
369
|
+
raise YouTrackAPIError(f"Failed to update custom fields: {str(e)}")
|
|
370
|
+
|
|
371
|
+
def _handle_state_transition(self, issue_id: str, target_state: str, use_commands: bool = False) -> bool:
|
|
372
|
+
"""
|
|
373
|
+
Handle state transitions using multiple approaches based on YouTrack configuration.
|
|
374
|
+
|
|
375
|
+
This method tries different approaches in order:
|
|
376
|
+
1. Direct Field Update API (works for some fields, may fail due to workflow restrictions)
|
|
377
|
+
2. Command-based approach (more reliable but can be blocked by permissions)
|
|
378
|
+
3. Event-based transitions for state machine workflows
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
issue_id: The issue identifier
|
|
382
|
+
target_state: Target state name
|
|
383
|
+
use_commands: Whether to prioritize command-based approach
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
True if transition succeeded, False otherwise
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
# Method 1: Direct Field Update (PROVEN TO WORK in testing)
|
|
390
|
+
logger.info(f"Attempting direct field update for issue {issue_id} to state '{target_state}'")
|
|
391
|
+
success = self._apply_direct_state_update(issue_id, target_state)
|
|
392
|
+
|
|
393
|
+
if success:
|
|
394
|
+
logger.info(f"Direct field update succeeded for issue {issue_id}")
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
# Method 2: Fallback to command-based approach if enabled
|
|
398
|
+
if use_commands:
|
|
399
|
+
logger.info(f"Direct update failed, trying command-based approach for issue {issue_id}")
|
|
400
|
+
try:
|
|
401
|
+
command_data = {
|
|
402
|
+
"query": f"State \"{target_state}\"",
|
|
403
|
+
"issues": [{"id": issue_id}]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
logger.info(f"Applying state transition command 'State \"{target_state}\"' to issue {issue_id}")
|
|
407
|
+
self.client.post("commands", data=command_data)
|
|
408
|
+
logger.info(f"Command-based transition succeeded for issue {issue_id}")
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
except Exception as cmd_error:
|
|
412
|
+
logger.warning(f"Command-based approach failed: {cmd_error}")
|
|
413
|
+
|
|
414
|
+
# Method 3: Try state machine detection and event-based transitions
|
|
415
|
+
logger.info(f"Trying state machine event-based approach for issue {issue_id}")
|
|
416
|
+
try:
|
|
417
|
+
# Query possible transitions (as recommended in analysis)
|
|
418
|
+
issue_fields = self.client.get(f"issues/{issue_id}/customFields?fields=name,possibleEvents(id,presentation),value(name),$type")
|
|
419
|
+
|
|
420
|
+
state_field = None
|
|
421
|
+
for field in issue_fields:
|
|
422
|
+
if field.get('name', '').lower() == 'state':
|
|
423
|
+
state_field = field
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
if state_field:
|
|
427
|
+
field_type = state_field.get('$type', '')
|
|
428
|
+
possible_events = state_field.get('possibleEvents', [])
|
|
429
|
+
|
|
430
|
+
# Check if this is a state machine workflow
|
|
431
|
+
if field_type == 'StateMachineIssueCustomField' and possible_events:
|
|
432
|
+
# Use event-based transition
|
|
433
|
+
logger.info(f"Detected state machine workflow for issue {issue_id}")
|
|
434
|
+
return self._apply_state_machine_transition(issue_id, target_state, possible_events)
|
|
435
|
+
|
|
436
|
+
except Exception as sm_error:
|
|
437
|
+
logger.warning(f"State machine detection failed: {sm_error}")
|
|
438
|
+
|
|
439
|
+
# All methods failed
|
|
440
|
+
logger.error(f"All state transition methods failed for issue {issue_id} to state '{target_state}'")
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.error(f"State transition failed for issue {issue_id} to state '{target_state}': {e}")
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
def _apply_state_machine_transition(self, issue_id: str, target_state: str, possible_events: List[Dict]) -> bool:
|
|
448
|
+
"""
|
|
449
|
+
Apply state machine-based transition using events.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
issue_id: The issue identifier
|
|
453
|
+
target_state: Target state name
|
|
454
|
+
possible_events: Available transition events
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
True if transition succeeded, False otherwise
|
|
458
|
+
"""
|
|
459
|
+
try:
|
|
460
|
+
# Find the event that leads to the target state
|
|
461
|
+
target_event = None
|
|
462
|
+
for event in possible_events:
|
|
463
|
+
# This would need more sophisticated matching logic
|
|
464
|
+
# based on the actual event structure in your YouTrack instance
|
|
465
|
+
event_presentation = event.get('presentation', '').lower()
|
|
466
|
+
if target_state.lower() in event_presentation:
|
|
467
|
+
target_event = event
|
|
468
|
+
break
|
|
469
|
+
|
|
470
|
+
if target_event:
|
|
471
|
+
# Apply event-based transition
|
|
472
|
+
update_data = {
|
|
473
|
+
"customFields": [{
|
|
474
|
+
"$type": "StateMachineIssueCustomField",
|
|
475
|
+
"name": "State",
|
|
476
|
+
"event": {
|
|
477
|
+
"$type": "Event",
|
|
478
|
+
"id": target_event.get('id')
|
|
479
|
+
}
|
|
480
|
+
}]
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
self.client.post(f"issues/{issue_id}", data=update_data)
|
|
484
|
+
logger.info(f"Applied state machine event '{target_event.get('id')}' to issue {issue_id}")
|
|
485
|
+
return True
|
|
486
|
+
else:
|
|
487
|
+
logger.warning(f"No suitable event found for state transition to '{target_state}'")
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
except Exception as e:
|
|
491
|
+
logger.error(f"State machine transition failed: {e}")
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
def _apply_direct_state_update(self, issue_id: str, target_state: str) -> bool:
|
|
495
|
+
"""
|
|
496
|
+
Apply direct state field update using proven working format.
|
|
497
|
+
|
|
498
|
+
Based on successful testing, the working format is:
|
|
499
|
+
{"customFields": [{"name": "State", "value": "In Progress"}]}
|
|
500
|
+
|
|
501
|
+
NOT complex objects or ID references.
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
# Use the proven simple string format that works
|
|
505
|
+
update_data = {
|
|
506
|
+
"customFields": [{
|
|
507
|
+
"name": "State",
|
|
508
|
+
"value": target_state # Simple string value - this is what works!
|
|
509
|
+
}]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
logger.info(f"Applying direct state update to issue {issue_id}: State -> '{target_state}'")
|
|
513
|
+
self.client.post(f"issues/{issue_id}", data=update_data)
|
|
514
|
+
|
|
515
|
+
logger.info(f"Direct state update successful for issue {issue_id}")
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.warning(f"Direct state update failed for issue {issue_id}: {e}")
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
def _update_other_custom_fields(self, issue_id: str, custom_fields: Dict[str, Any], validate: bool, use_commands: bool) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Update non-state custom fields, prioritizing direct field updates.
|
|
525
|
+
|
|
526
|
+
Based on successful testing, uses simple values where possible:
|
|
527
|
+
- Strings: "Critical", "admin", "Bug"
|
|
528
|
+
- NOT complex objects: {"name": "Critical", "id": "123"}
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
issue_id: Issue identifier
|
|
532
|
+
custom_fields: Dictionary of field names and values
|
|
533
|
+
validate: Whether to validate field values
|
|
534
|
+
use_commands: Whether to try command-based approach as fallback
|
|
535
|
+
"""
|
|
536
|
+
# Method 1: Direct field update approach (primary method)
|
|
537
|
+
try:
|
|
538
|
+
# Always get issue data to extract project ID for schema lookups
|
|
539
|
+
issue_data = self.get_issue(issue_id)
|
|
540
|
+
|
|
541
|
+
# Validate fields if requested
|
|
542
|
+
if validate:
|
|
543
|
+
project_id = None
|
|
544
|
+
if hasattr(issue_data, 'project') and issue_data.project:
|
|
545
|
+
if isinstance(issue_data.project, dict):
|
|
546
|
+
project_id = issue_data.project.get('id')
|
|
547
|
+
else:
|
|
548
|
+
project_id = getattr(issue_data.project, 'id', None)
|
|
549
|
+
|
|
550
|
+
if project_id:
|
|
551
|
+
for field_name, field_value in custom_fields.items():
|
|
552
|
+
is_valid = self._validate_custom_field_value(project_id, field_name, field_value)
|
|
553
|
+
if not is_valid:
|
|
554
|
+
raise YouTrackAPIError(f"Custom field validation failed for '{field_name}': '{field_value}' is not a valid value")
|
|
555
|
+
else:
|
|
556
|
+
logger.warning("Could not get project ID for validation, skipping validation")
|
|
557
|
+
|
|
558
|
+
# YouTrack requires proper object types with actual IDs for custom field updates
|
|
559
|
+
# Try to get project ID for schema lookups (optional for enhanced object creation)
|
|
560
|
+
project_id = None
|
|
561
|
+
|
|
562
|
+
if hasattr(issue_data, 'project') and issue_data.project:
|
|
563
|
+
if isinstance(issue_data.project, dict):
|
|
564
|
+
project_id = issue_data.project.get('id')
|
|
565
|
+
else:
|
|
566
|
+
project_id = getattr(issue_data.project, 'id', None)
|
|
567
|
+
|
|
568
|
+
# If we can't get project ID, fall back to simple $type approach
|
|
569
|
+
if not project_id:
|
|
570
|
+
logger.warning("Could not determine project ID for enhanced object creation, using simple $type approach")
|
|
571
|
+
use_simple_approach = True
|
|
572
|
+
else:
|
|
573
|
+
use_simple_approach = False
|
|
574
|
+
|
|
575
|
+
update_data = {"customFields": []}
|
|
576
|
+
|
|
577
|
+
for field_name, raw_field_value in custom_fields.items():
|
|
578
|
+
# Normalize complex object formats to simple strings first
|
|
579
|
+
field_value = self._normalize_field_value(raw_field_value)
|
|
580
|
+
|
|
581
|
+
# Determine field type and construct proper object with actual ID
|
|
582
|
+
if use_simple_approach:
|
|
583
|
+
# Fallback to simple $type approach when project ID is not available
|
|
584
|
+
if field_name.lower() in ['state']:
|
|
585
|
+
field_type = "StateIssueCustomField"
|
|
586
|
+
elif field_name.lower() in ['priority', 'type']:
|
|
587
|
+
field_type = "SingleEnumIssueCustomField"
|
|
588
|
+
elif field_name.lower() in ['assignee', 'reporter']:
|
|
589
|
+
field_type = "SingleUserIssueCustomField"
|
|
590
|
+
elif field_name.lower() in ['estimation', 'spent time']:
|
|
591
|
+
field_type = "PeriodIssueCustomField"
|
|
592
|
+
else:
|
|
593
|
+
field_type = "SingleEnumIssueCustomField"
|
|
594
|
+
|
|
595
|
+
field_data = {
|
|
596
|
+
"$type": field_type,
|
|
597
|
+
"name": field_name,
|
|
598
|
+
"value": field_value
|
|
599
|
+
}
|
|
600
|
+
else:
|
|
601
|
+
# Enhanced approach with proper YouTrack objects and actual IDs
|
|
602
|
+
# Handle Estimation with proper PeriodValue format
|
|
603
|
+
if field_name.lower() in ['estimation']:
|
|
604
|
+
# Estimation REQUIRES PeriodValue format, not simple strings
|
|
605
|
+
field_data = self._create_period_field_object(field_name, field_value)
|
|
606
|
+
elif field_name.lower() in ['state']:
|
|
607
|
+
field_data = self._create_state_field_object(project_id, field_name, field_value)
|
|
608
|
+
elif field_name.lower() in ['priority', 'type']:
|
|
609
|
+
field_data = self._create_enum_field_object(project_id, field_name, field_value)
|
|
610
|
+
elif field_name.lower() in ['assignee', 'reporter']:
|
|
611
|
+
field_data = self._create_user_field_object(field_name, field_value)
|
|
612
|
+
elif field_name.lower() in ['spent time']:
|
|
613
|
+
field_data = self._create_period_field_object(field_name, field_value)
|
|
614
|
+
elif field_name.lower() in self._VERSION_FIELD_NAMES:
|
|
615
|
+
# Detect version/sprint fields by well-known name
|
|
616
|
+
field_data = self._create_version_field_object(project_id, field_name, field_value, multi=True)
|
|
617
|
+
else:
|
|
618
|
+
# Query the schema to detect version/sprint fields
|
|
619
|
+
value_type = self._get_field_value_type(project_id, field_name)
|
|
620
|
+
if value_type == "version":
|
|
621
|
+
field_data = self._create_version_field_object(project_id, field_name, field_value, multi=True)
|
|
622
|
+
else:
|
|
623
|
+
# Default to enum for unknown fields
|
|
624
|
+
field_data = self._create_enum_field_object(project_id, field_name, field_value)
|
|
625
|
+
|
|
626
|
+
if field_data:
|
|
627
|
+
update_data["customFields"].append(field_data)
|
|
628
|
+
|
|
629
|
+
logger.info(f"Updating custom fields for issue {issue_id} using proper YouTrack objects")
|
|
630
|
+
logger.info(f"Update payload: {json.dumps(update_data, indent=2)}")
|
|
631
|
+
self.client.post(f"issues/{issue_id}", data=update_data)
|
|
632
|
+
logger.info(f"Direct field update succeeded for issue {issue_id}")
|
|
633
|
+
|
|
634
|
+
except Exception as direct_error:
|
|
635
|
+
logger.warning(f"Direct field update failed: {direct_error}")
|
|
636
|
+
|
|
637
|
+
# Method 2: Fallback to command-based approach if enabled
|
|
638
|
+
if use_commands:
|
|
639
|
+
logger.info(f"Trying command-based approach as fallback for issue {issue_id}")
|
|
640
|
+
try:
|
|
641
|
+
self._apply_commands_update(issue_id, custom_fields)
|
|
642
|
+
logger.info(f"Command-based update succeeded for issue {issue_id}")
|
|
643
|
+
return
|
|
644
|
+
except Exception as cmd_error:
|
|
645
|
+
logger.warning(f"Command-based approach also failed: {cmd_error}")
|
|
646
|
+
|
|
647
|
+
# If both direct and command approaches fail, raise the original error with full details
|
|
648
|
+
error_msg = f"Direct field update failed"
|
|
649
|
+
if hasattr(direct_error, 'response') and hasattr(direct_error.response, 'json'):
|
|
650
|
+
try:
|
|
651
|
+
error_details = direct_error.response.json()
|
|
652
|
+
if 'error_description' in error_details:
|
|
653
|
+
error_msg += f": {error_details['error_description']}"
|
|
654
|
+
elif 'error' in error_details:
|
|
655
|
+
error_msg += f": {error_details['error']}"
|
|
656
|
+
else:
|
|
657
|
+
error_msg += f": {str(direct_error)}"
|
|
658
|
+
except:
|
|
659
|
+
error_msg += f": {str(direct_error)}"
|
|
660
|
+
else:
|
|
661
|
+
error_msg += f": {str(direct_error)}"
|
|
662
|
+
|
|
663
|
+
raise YouTrackAPIError(error_msg)
|
|
664
|
+
|
|
665
|
+
def _create_enum_field_object(self, project_id: str, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
666
|
+
"""Create proper EnumBundleElement object with actual ID."""
|
|
667
|
+
try:
|
|
668
|
+
# Normalize field value first
|
|
669
|
+
normalized_value = self._normalize_field_value(field_value)
|
|
670
|
+
|
|
671
|
+
# Get allowed values to find the actual ID
|
|
672
|
+
from youtrack_mcp.api.projects import ProjectsClient
|
|
673
|
+
projects_client = ProjectsClient(self.client)
|
|
674
|
+
allowed_values = projects_client.get_custom_field_allowed_values(project_id, field_name)
|
|
675
|
+
|
|
676
|
+
# Find the matching value by name (case-insensitive)
|
|
677
|
+
value_id = None
|
|
678
|
+
for value in allowed_values:
|
|
679
|
+
if value.get('name', '').lower() == normalized_value.lower():
|
|
680
|
+
value_id = value.get('id')
|
|
681
|
+
break
|
|
682
|
+
|
|
683
|
+
if value_id:
|
|
684
|
+
return {
|
|
685
|
+
"$type": "SingleEnumIssueCustomField",
|
|
686
|
+
"name": field_name,
|
|
687
|
+
"value": {
|
|
688
|
+
"$type": "EnumBundleElement",
|
|
689
|
+
"id": value_id,
|
|
690
|
+
"name": normalized_value
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else:
|
|
694
|
+
logger.warning(f"Could not find ID for enum value '{field_value}' in field '{field_name}', trying simple enum object")
|
|
695
|
+
# Try simple enum object format if ID lookup fails
|
|
696
|
+
return {
|
|
697
|
+
"$type": "SingleEnumIssueCustomField",
|
|
698
|
+
"name": field_name,
|
|
699
|
+
"value": {
|
|
700
|
+
"$type": "EnumBundleElement",
|
|
701
|
+
"name": normalized_value
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
except Exception as e:
|
|
705
|
+
logger.warning(f"Error creating enum field object for '{field_name}': {e}, trying simple enum object")
|
|
706
|
+
# Fallback to simple enum object (no ID)
|
|
707
|
+
return {
|
|
708
|
+
"$type": "SingleEnumIssueCustomField",
|
|
709
|
+
"name": field_name,
|
|
710
|
+
"value": {
|
|
711
|
+
"$type": "EnumBundleElement",
|
|
712
|
+
"name": normalized_value
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
def _create_state_field_object(self, project_id: str, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
717
|
+
"""Create proper StateBundleElement object with actual ID."""
|
|
718
|
+
try:
|
|
719
|
+
# Normalize field value first
|
|
720
|
+
normalized_value = self._normalize_field_value(field_value)
|
|
721
|
+
|
|
722
|
+
# Get allowed values to find the actual ID
|
|
723
|
+
from youtrack_mcp.api.projects import ProjectsClient
|
|
724
|
+
projects_client = ProjectsClient(self.client)
|
|
725
|
+
allowed_values = projects_client.get_custom_field_allowed_values(project_id, field_name)
|
|
726
|
+
|
|
727
|
+
# Find the matching state by name (case-insensitive)
|
|
728
|
+
state_id = None
|
|
729
|
+
for value in allowed_values:
|
|
730
|
+
if value.get('name', '').lower() == normalized_value.lower():
|
|
731
|
+
state_id = value.get('id')
|
|
732
|
+
break
|
|
733
|
+
|
|
734
|
+
if state_id:
|
|
735
|
+
return {
|
|
736
|
+
"$type": "StateIssueCustomField",
|
|
737
|
+
"name": field_name,
|
|
738
|
+
"value": {
|
|
739
|
+
"$type": "StateBundleElement",
|
|
740
|
+
"id": state_id,
|
|
741
|
+
"name": field_value
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else:
|
|
745
|
+
logger.warning(f"Could not find ID for state value '{field_value}' in field '{field_name}', using simple value")
|
|
746
|
+
return {
|
|
747
|
+
"$type": "StateIssueCustomField",
|
|
748
|
+
"name": field_name,
|
|
749
|
+
"value": field_value
|
|
750
|
+
}
|
|
751
|
+
except Exception as e:
|
|
752
|
+
logger.warning(f"Error creating state field object for '{field_name}': {e}, using simple value")
|
|
753
|
+
return {
|
|
754
|
+
"$type": "StateIssueCustomField",
|
|
755
|
+
"name": field_name,
|
|
756
|
+
"value": field_value
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
def _create_user_field_object(self, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
760
|
+
"""Create proper User object with actual ID."""
|
|
761
|
+
try:
|
|
762
|
+
# Normalize field value first
|
|
763
|
+
normalized_value = self._normalize_field_value(field_value)
|
|
764
|
+
|
|
765
|
+
# Get user ID by login
|
|
766
|
+
from youtrack_mcp.api.users import UsersClient
|
|
767
|
+
users_client = UsersClient(self.client)
|
|
768
|
+
user_data = users_client.get_user(normalized_value)
|
|
769
|
+
|
|
770
|
+
if user_data and hasattr(user_data, 'id') and user_data.id:
|
|
771
|
+
return {
|
|
772
|
+
"$type": "SingleUserIssueCustomField",
|
|
773
|
+
"name": field_name,
|
|
774
|
+
"value": {
|
|
775
|
+
"$type": "User",
|
|
776
|
+
"id": user_data.id,
|
|
777
|
+
"login": normalized_value
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else:
|
|
781
|
+
logger.warning(f"Could not find user ID for login '{field_value}', using simple value")
|
|
782
|
+
return {
|
|
783
|
+
"$type": "SingleUserIssueCustomField",
|
|
784
|
+
"name": field_name,
|
|
785
|
+
"value": field_value
|
|
786
|
+
}
|
|
787
|
+
except Exception as e:
|
|
788
|
+
logger.warning(f"Error creating user field object for '{field_name}': {e}, using simple value")
|
|
789
|
+
return {
|
|
790
|
+
"$type": "SingleUserIssueCustomField",
|
|
791
|
+
"name": field_name,
|
|
792
|
+
"value": field_value
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
def _create_period_field_object(self, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
796
|
+
"""Create proper period field object with PeriodValue format."""
|
|
797
|
+
try:
|
|
798
|
+
# Normalize field value first
|
|
799
|
+
normalized_value = self._normalize_field_value(field_value)
|
|
800
|
+
|
|
801
|
+
# Convert simple time strings to proper PeriodValue format
|
|
802
|
+
# Examples: "4h" -> 240 minutes, "30m" -> 30 minutes, "2h 30m" -> 150 minutes
|
|
803
|
+
|
|
804
|
+
minutes = self._parse_time_to_minutes(normalized_value)
|
|
805
|
+
|
|
806
|
+
if minutes is not None:
|
|
807
|
+
return {
|
|
808
|
+
"$type": "PeriodIssueCustomField",
|
|
809
|
+
"name": field_name,
|
|
810
|
+
"value": {
|
|
811
|
+
"$type": "PeriodValue",
|
|
812
|
+
"minutes": minutes
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
else:
|
|
816
|
+
# Fallback to simple value if parsing fails
|
|
817
|
+
logger.warning(f"Could not parse period value '{field_value}' for field '{field_name}', using simple value")
|
|
818
|
+
return {
|
|
819
|
+
"$type": "PeriodIssueCustomField",
|
|
820
|
+
"name": field_name,
|
|
821
|
+
"value": field_value
|
|
822
|
+
}
|
|
823
|
+
except Exception as e:
|
|
824
|
+
logger.warning(f"Error creating period field object for '{field_name}': {e}, using simple value")
|
|
825
|
+
return {
|
|
826
|
+
"$type": "PeriodIssueCustomField",
|
|
827
|
+
"name": field_name,
|
|
828
|
+
"value": field_value
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
def _parse_time_to_minutes(self, time_str: str) -> Optional[int]:
|
|
832
|
+
"""Parse time string to minutes for PeriodValue."""
|
|
833
|
+
try:
|
|
834
|
+
time_str = time_str.strip().lower()
|
|
835
|
+
total_minutes = 0
|
|
836
|
+
|
|
837
|
+
# Handle formats like "4h", "30m", "2h 30m", "1h30m"
|
|
838
|
+
|
|
839
|
+
# Extract hours
|
|
840
|
+
hours_match = re.search(r'(\d+)\s*h', time_str)
|
|
841
|
+
if hours_match:
|
|
842
|
+
total_minutes += int(hours_match.group(1)) * 60
|
|
843
|
+
|
|
844
|
+
# Extract minutes
|
|
845
|
+
minutes_match = re.search(r'(\d+)\s*m', time_str)
|
|
846
|
+
if minutes_match:
|
|
847
|
+
total_minutes += int(minutes_match.group(1))
|
|
848
|
+
|
|
849
|
+
# If no h or m found, assume it's just minutes
|
|
850
|
+
if not hours_match and not minutes_match:
|
|
851
|
+
# Try to parse as plain number (minutes)
|
|
852
|
+
if time_str.isdigit():
|
|
853
|
+
total_minutes = int(time_str)
|
|
854
|
+
else:
|
|
855
|
+
return None
|
|
856
|
+
|
|
857
|
+
return total_minutes if total_minutes > 0 else None
|
|
858
|
+
|
|
859
|
+
except Exception as e:
|
|
860
|
+
logger.debug(f"Failed to parse time string '{time_str}': {e}")
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
def _apply_commands_update(self, issue_id: str, custom_fields: Dict[str, Any]) -> None:
|
|
864
|
+
"""
|
|
865
|
+
Apply custom field updates using the command-based approach.
|
|
866
|
+
This method is a fallback and might not be as reliable as direct field updates.
|
|
867
|
+
"""
|
|
868
|
+
try:
|
|
869
|
+
command_data = {"query": "updateCustomFields"}
|
|
870
|
+
for field_name, field_value in custom_fields.items():
|
|
871
|
+
command_data["query"] += f" {field_name} {field_value}"
|
|
872
|
+
|
|
873
|
+
command_data["issues"] = [{"id": issue_id}]
|
|
874
|
+
|
|
875
|
+
logger.info(f"Applying command-based update for issue {issue_id} with query: {command_data['query']}")
|
|
876
|
+
self.client.post("commands", data=command_data)
|
|
877
|
+
logger.info(f"Command-based update succeeded for issue {issue_id}")
|
|
878
|
+
except Exception as e:
|
|
879
|
+
logger.warning(f"Command-based update failed: {e}")
|
|
880
|
+
raise
|
|
881
|
+
|
|
882
|
+
def get_issue_custom_fields(self, issue_id: str) -> Dict[str, Any]:
|
|
883
|
+
"""
|
|
884
|
+
Get all custom fields for a specific issue.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
issue_id: The issue ID or readable ID
|
|
888
|
+
|
|
889
|
+
Returns:
|
|
890
|
+
Dictionary of custom field name-value pairs
|
|
891
|
+
"""
|
|
892
|
+
fields = "customFields(id,name,value($type,name,text,id,login))"
|
|
893
|
+
response = self.client.get(f"issues/{issue_id}?fields={fields}")
|
|
894
|
+
|
|
895
|
+
custom_fields = {}
|
|
896
|
+
if "customFields" in response:
|
|
897
|
+
for field in response["customFields"]:
|
|
898
|
+
field_name = field.get("name", "")
|
|
899
|
+
field_value = self._extract_custom_field_value(field.get("value"))
|
|
900
|
+
custom_fields[field_name] = field_value
|
|
901
|
+
|
|
902
|
+
return custom_fields
|
|
903
|
+
|
|
904
|
+
def validate_custom_field_value(
|
|
905
|
+
self,
|
|
906
|
+
project_id: str,
|
|
907
|
+
field_name: str,
|
|
908
|
+
field_value: Any
|
|
909
|
+
) -> Dict[str, Any]:
|
|
910
|
+
"""
|
|
911
|
+
Validate a custom field value against project schema.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
project_id: The project ID
|
|
915
|
+
field_name: The custom field name
|
|
916
|
+
field_value: The value to validate
|
|
917
|
+
|
|
918
|
+
Returns:
|
|
919
|
+
Dictionary with validation result and details
|
|
920
|
+
"""
|
|
921
|
+
try:
|
|
922
|
+
is_valid = self._validate_custom_field_value(project_id, field_name, field_value)
|
|
923
|
+
|
|
924
|
+
if is_valid:
|
|
925
|
+
return {
|
|
926
|
+
"valid": True,
|
|
927
|
+
"field": field_name,
|
|
928
|
+
"value": field_value,
|
|
929
|
+
"message": "Valid"
|
|
930
|
+
}
|
|
931
|
+
else:
|
|
932
|
+
# Get available values for better error messages
|
|
933
|
+
available_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
934
|
+
suggestion = f"Available values: {', '.join(map(str, available_values))}" if available_values else "Check field configuration"
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
"valid": False,
|
|
938
|
+
"field": field_name,
|
|
939
|
+
"value": field_value,
|
|
940
|
+
"error": f"Invalid value '{field_value}' for field '{field_name}'",
|
|
941
|
+
"suggestion": suggestion
|
|
942
|
+
}
|
|
943
|
+
except Exception as e:
|
|
944
|
+
return {
|
|
945
|
+
"valid": False,
|
|
946
|
+
"field": field_name,
|
|
947
|
+
"value": field_value,
|
|
948
|
+
"error": f"Validation error: {str(e)}",
|
|
949
|
+
"suggestion": "Check field name and project configuration"
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
def batch_update_custom_fields(
|
|
953
|
+
self,
|
|
954
|
+
updates: List[Dict[str, Any]]
|
|
955
|
+
) -> List[Dict[str, Any]]:
|
|
956
|
+
"""
|
|
957
|
+
Update custom fields for multiple issues in a single operation.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
updates: List of update dictionaries with format:
|
|
961
|
+
[{"issue_id": "DEMO-123", "fields": {"Priority": "High"}}]
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
List of update results with success/error status
|
|
965
|
+
"""
|
|
966
|
+
results = []
|
|
967
|
+
|
|
968
|
+
for update in updates:
|
|
969
|
+
issue_id = update.get("issue_id")
|
|
970
|
+
fields = update.get("fields", {})
|
|
971
|
+
|
|
972
|
+
if not issue_id:
|
|
973
|
+
results.append({
|
|
974
|
+
"issue_id": None,
|
|
975
|
+
"status": "error",
|
|
976
|
+
"error": "Missing issue_id in update"
|
|
977
|
+
})
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
if not fields:
|
|
981
|
+
results.append({
|
|
982
|
+
"issue_id": issue_id,
|
|
983
|
+
"status": "skipped",
|
|
984
|
+
"message": "No fields to update"
|
|
985
|
+
})
|
|
986
|
+
continue
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
updated_issue = self.update_issue_custom_fields(
|
|
990
|
+
issue_id=issue_id,
|
|
991
|
+
custom_fields=fields,
|
|
992
|
+
validate=update.get("validate", True)
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
results.append({
|
|
996
|
+
"issue_id": issue_id,
|
|
997
|
+
"status": "success",
|
|
998
|
+
"updated_fields": list(fields.keys()),
|
|
999
|
+
"issue_data": updated_issue.model_dump() if hasattr(updated_issue, 'model_dump') else updated_issue
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
except Exception as e:
|
|
1003
|
+
results.append({
|
|
1004
|
+
"issue_id": issue_id,
|
|
1005
|
+
"status": "error",
|
|
1006
|
+
"error": str(e),
|
|
1007
|
+
"attempted_fields": list(fields.keys())
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
return results
|
|
1011
|
+
|
|
1012
|
+
def search_issues(self, query: str, limit: int = 10) -> List[Issue]:
|
|
1013
|
+
"""
|
|
1014
|
+
Search for issues using YouTrack query language.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
query: The search query
|
|
1018
|
+
limit: Maximum number of issues to return
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
List of matching issues
|
|
1022
|
+
"""
|
|
1023
|
+
# Request additional fields to ensure we get summary
|
|
1024
|
+
fields = "id,idReadable,summary,description,created,updated,project,reporter,assignee,customFields"
|
|
1025
|
+
params = {"query": query, "$top": limit, "fields": fields}
|
|
1026
|
+
response = self.client.get("issues", params=params)
|
|
1027
|
+
|
|
1028
|
+
issues = []
|
|
1029
|
+
if isinstance(response, list):
|
|
1030
|
+
for issue_data in response:
|
|
1031
|
+
try:
|
|
1032
|
+
issues.append(Issue.model_validate(issue_data))
|
|
1033
|
+
except Exception as e:
|
|
1034
|
+
logger.warning(f"Error validating issue: {e}")
|
|
1035
|
+
# Create a basic issue with id
|
|
1036
|
+
issue_id = issue_data.get(
|
|
1037
|
+
"id", str(issue_data.get("created", "unknown"))
|
|
1038
|
+
)
|
|
1039
|
+
issues.append(
|
|
1040
|
+
Issue(
|
|
1041
|
+
id=issue_id,
|
|
1042
|
+
summary=issue_data.get(
|
|
1043
|
+
"summary", f"Issue {issue_id}"
|
|
1044
|
+
),
|
|
1045
|
+
)
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
return issues
|
|
1049
|
+
|
|
1050
|
+
def add_comment(self, issue_id: str, text: str) -> Dict[str, Any]:
|
|
1051
|
+
"""
|
|
1052
|
+
Add a comment to an issue.
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
issue_id: The issue ID or readable ID
|
|
1056
|
+
text: The comment text
|
|
1057
|
+
|
|
1058
|
+
Returns:
|
|
1059
|
+
The created comment data
|
|
1060
|
+
"""
|
|
1061
|
+
data = {"text": text}
|
|
1062
|
+
return self.client.post(f"issues/{issue_id}/comments", data=data)
|
|
1063
|
+
|
|
1064
|
+
def get_attachment_content(
|
|
1065
|
+
self, issue_id: str, attachment_id: str
|
|
1066
|
+
) -> bytes:
|
|
1067
|
+
"""
|
|
1068
|
+
Get the content of an attachment with file size validation.
|
|
1069
|
+
|
|
1070
|
+
Args:
|
|
1071
|
+
issue_id: The issue ID or readable ID
|
|
1072
|
+
attachment_id: The attachment ID
|
|
1073
|
+
|
|
1074
|
+
Returns:
|
|
1075
|
+
The attachment content as bytes
|
|
1076
|
+
|
|
1077
|
+
Raises:
|
|
1078
|
+
ValueError: If attachment not found or file too large
|
|
1079
|
+
YouTrackAPIError: If API request fails
|
|
1080
|
+
"""
|
|
1081
|
+
# First, get the attachment metadata to get the URL and size
|
|
1082
|
+
issue_response = self.client.get(
|
|
1083
|
+
f"issues/{issue_id}?fields=attachments(id,url,size,name,mimeType)"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# Find the attachment with the matching ID
|
|
1087
|
+
attachment_info = None
|
|
1088
|
+
if "attachments" in issue_response:
|
|
1089
|
+
for attachment in issue_response["attachments"]:
|
|
1090
|
+
if attachment.get("id") == attachment_id:
|
|
1091
|
+
attachment_info = attachment
|
|
1092
|
+
break
|
|
1093
|
+
|
|
1094
|
+
if not attachment_info:
|
|
1095
|
+
raise ValueError(
|
|
1096
|
+
f"Attachment {attachment_id} not found in issue {issue_id}"
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
# Check file size limit for base64 encoding
|
|
1100
|
+
# Base64 encoding increases size by ~33%, so for 1MB base64 limit, max original size is ~750KB
|
|
1101
|
+
MAX_ORIGINAL_SIZE = 750 * 1024 # 750KB original file
|
|
1102
|
+
MAX_BASE64_SIZE = (
|
|
1103
|
+
1024 * 1024
|
|
1104
|
+
) # 1MB after base64 encoding (Claude Desktop limit)
|
|
1105
|
+
|
|
1106
|
+
file_size = attachment_info.get("size", 0)
|
|
1107
|
+
filename = attachment_info.get("name", attachment_id)
|
|
1108
|
+
mime_type = attachment_info.get("mimeType", "unknown")
|
|
1109
|
+
|
|
1110
|
+
if file_size > MAX_ORIGINAL_SIZE:
|
|
1111
|
+
# Calculate what the base64 size would be
|
|
1112
|
+
estimated_base64_size = int(file_size * 1.33)
|
|
1113
|
+
raise ValueError(
|
|
1114
|
+
f"Attachment '{filename}' ({mime_type}) is too large "
|
|
1115
|
+
f"({file_size:,} bytes → ~{estimated_base64_size:,} bytes after base64 encoding). "
|
|
1116
|
+
f"Maximum allowed: {MAX_ORIGINAL_SIZE:,} bytes original (~{MAX_BASE64_SIZE:,} bytes base64)."
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
attachment_url = attachment_info.get("url")
|
|
1120
|
+
if not attachment_url:
|
|
1121
|
+
raise ValueError(
|
|
1122
|
+
f"No download URL found for attachment {attachment_id}"
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# The URL in the attachment data is relative to the base URL
|
|
1126
|
+
if attachment_url.startswith("/"):
|
|
1127
|
+
attachment_url = attachment_url[1:] # Remove leading slash
|
|
1128
|
+
|
|
1129
|
+
# Construct the full URL
|
|
1130
|
+
base_url = self.client.base_url
|
|
1131
|
+
if base_url.endswith("/api"):
|
|
1132
|
+
base_url = base_url[:-4] # Remove '/api' suffix
|
|
1133
|
+
|
|
1134
|
+
full_url = f"{base_url}/{attachment_url}"
|
|
1135
|
+
|
|
1136
|
+
# Make the request to get the attachment content
|
|
1137
|
+
from youtrack_mcp.api.client import YouTrackAPIError
|
|
1138
|
+
|
|
1139
|
+
response = self.client.session.get(full_url)
|
|
1140
|
+
|
|
1141
|
+
# Check for errors
|
|
1142
|
+
if response.status_code >= 400:
|
|
1143
|
+
error_msg = (
|
|
1144
|
+
f"Error getting attachment content: {response.status_code}"
|
|
1145
|
+
)
|
|
1146
|
+
raise YouTrackAPIError(error_msg, response.status_code, response)
|
|
1147
|
+
|
|
1148
|
+
# Double-check the actual content size
|
|
1149
|
+
content_length = len(response.content)
|
|
1150
|
+
if content_length > MAX_ORIGINAL_SIZE:
|
|
1151
|
+
estimated_base64_size = int(content_length * 1.33)
|
|
1152
|
+
raise ValueError(
|
|
1153
|
+
f"Downloaded content size ({content_length:,} bytes → ~{estimated_base64_size:,} bytes base64) "
|
|
1154
|
+
f"exceeds maximum allowed size ({MAX_ORIGINAL_SIZE:,} bytes original). "
|
|
1155
|
+
f"The file may have been modified since metadata was fetched."
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
# Return the binary content
|
|
1159
|
+
return response.content
|
|
1160
|
+
|
|
1161
|
+
def delete_attachment(self, issue_id: str, attachment_id: str) -> None:
|
|
1162
|
+
"""
|
|
1163
|
+
Delete an attachment from an issue.
|
|
1164
|
+
|
|
1165
|
+
Args:
|
|
1166
|
+
issue_id: The issue ID or readable ID
|
|
1167
|
+
attachment_id: The attachment ID to delete
|
|
1168
|
+
|
|
1169
|
+
Raises:
|
|
1170
|
+
AttachmentNotFoundError: If attachment not found
|
|
1171
|
+
YouTrackAPIError: If API request fails (e.g., insufficient permissions)
|
|
1172
|
+
"""
|
|
1173
|
+
# First verify the attachment exists
|
|
1174
|
+
issue_response = self.client.get(
|
|
1175
|
+
f"issues/{issue_id}?fields=attachments(id,name)"
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
# Check if attachment exists
|
|
1179
|
+
attachment_found = False
|
|
1180
|
+
if "attachments" in issue_response:
|
|
1181
|
+
for attachment in issue_response["attachments"]:
|
|
1182
|
+
if attachment.get("id") == attachment_id:
|
|
1183
|
+
attachment_found = True
|
|
1184
|
+
break
|
|
1185
|
+
|
|
1186
|
+
if not attachment_found:
|
|
1187
|
+
raise AttachmentNotFoundError(
|
|
1188
|
+
f"Attachment {attachment_id} not found in issue {issue_id}"
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
# Delete the attachment
|
|
1192
|
+
self.client.delete(f"issues/{issue_id}/attachments/{attachment_id}")
|
|
1193
|
+
|
|
1194
|
+
def _get_internal_id(self, issue_id: str) -> str:
|
|
1195
|
+
"""Convert issue ID to internal format if needed."""
|
|
1196
|
+
try:
|
|
1197
|
+
internal_id = self.client.get(f"issues/{issue_id}?fields=id")["id"]
|
|
1198
|
+
return internal_id
|
|
1199
|
+
except Exception:
|
|
1200
|
+
# If that fails, assume the issue_id is already internal
|
|
1201
|
+
return issue_id
|
|
1202
|
+
|
|
1203
|
+
def _get_readable_id(self, issue_id: str) -> str:
|
|
1204
|
+
"""
|
|
1205
|
+
Convert an internal issue ID (like 3-37) to readable ID (like DEMO-37).
|
|
1206
|
+
If it's already a readable ID, return as-is.
|
|
1207
|
+
|
|
1208
|
+
Args:
|
|
1209
|
+
issue_id: Issue ID (internal like '3-41' or readable like 'DEMO-123')
|
|
1210
|
+
|
|
1211
|
+
Returns:
|
|
1212
|
+
Readable project ID (like 'DEMO-41')
|
|
1213
|
+
"""
|
|
1214
|
+
try:
|
|
1215
|
+
# If it doesn't look like an internal ID, return as-is
|
|
1216
|
+
if not ("-" in issue_id and issue_id.replace("-", "").isdigit()):
|
|
1217
|
+
return issue_id
|
|
1218
|
+
|
|
1219
|
+
# Fetch the issue to get its readable ID
|
|
1220
|
+
issue = self.client.get(f"issues/{issue_id}?fields=idReadable")
|
|
1221
|
+
return issue.get("idReadable", issue_id)
|
|
1222
|
+
except Exception:
|
|
1223
|
+
# If we can't get the readable ID, return the original
|
|
1224
|
+
return issue_id
|
|
1225
|
+
|
|
1226
|
+
def link_issues(
|
|
1227
|
+
self, source_issue_id: str, target_issue_id: str, link_type: str
|
|
1228
|
+
) -> dict:
|
|
1229
|
+
"""
|
|
1230
|
+
Link two issues together using Commands API.
|
|
1231
|
+
|
|
1232
|
+
Args:
|
|
1233
|
+
source_issue_id: The ID of the source issue
|
|
1234
|
+
target_issue_id: The ID of the target issue
|
|
1235
|
+
link_type: The type of link (e.g., 'Relates', 'Duplicates', 'Depends on')
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
The created link data
|
|
1239
|
+
"""
|
|
1240
|
+
# Map link types to correct YouTrack command syntax
|
|
1241
|
+
link_command_map = {
|
|
1242
|
+
"relates": "relates to",
|
|
1243
|
+
"depends on": "depends on",
|
|
1244
|
+
"duplicates": "duplicates",
|
|
1245
|
+
"is duplicated by": "is duplicated by",
|
|
1246
|
+
"is required for": "is required for",
|
|
1247
|
+
"parent for": "parent for",
|
|
1248
|
+
"subtask": "subtask of",
|
|
1249
|
+
"subtask of": "subtask of",
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
# Get internal IDs for both issues (Commands API requires internal IDs in issues array)
|
|
1253
|
+
source_internal_id = self._get_internal_id(source_issue_id)
|
|
1254
|
+
target_internal_id = self._get_internal_id(target_issue_id)
|
|
1255
|
+
|
|
1256
|
+
# Get readable IDs for command text (Commands API expects readable IDs in command)
|
|
1257
|
+
target_readable_id = self._get_readable_id(target_issue_id)
|
|
1258
|
+
|
|
1259
|
+
# Normalize the link_type to lowercase and map to command
|
|
1260
|
+
link_type_lower = link_type.lower()
|
|
1261
|
+
command_phrase = link_command_map.get(link_type_lower, link_type_lower)
|
|
1262
|
+
|
|
1263
|
+
# Build the command using correct YouTrack syntax with readable target ID
|
|
1264
|
+
# Commands expect readable IDs like "DEMO-37" in command text, but internal IDs in issues array
|
|
1265
|
+
command = f"{command_phrase} {target_readable_id}"
|
|
1266
|
+
|
|
1267
|
+
command_data = {
|
|
1268
|
+
"query": command,
|
|
1269
|
+
"issues": [{"id": source_internal_id}],
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
response = self.client.post("commands", data=command_data)
|
|
1273
|
+
|
|
1274
|
+
# Return success response if the command executed
|
|
1275
|
+
if isinstance(response, dict):
|
|
1276
|
+
return {
|
|
1277
|
+
"status": "success",
|
|
1278
|
+
"message": f"Successfully linked {source_issue_id} to {target_issue_id} with relationship '{link_type}'",
|
|
1279
|
+
"command": command,
|
|
1280
|
+
"source_issue": source_issue_id,
|
|
1281
|
+
"target_issue": target_issue_id,
|
|
1282
|
+
"link_type": link_type,
|
|
1283
|
+
"internal_ids": {
|
|
1284
|
+
"source": source_internal_id,
|
|
1285
|
+
"target": target_internal_id,
|
|
1286
|
+
},
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
return response
|
|
1290
|
+
|
|
1291
|
+
def get_issue_links(self, issue_id: str) -> dict:
|
|
1292
|
+
"""
|
|
1293
|
+
Get all links for an issue.
|
|
1294
|
+
|
|
1295
|
+
Args:
|
|
1296
|
+
issue_id: The ID of the issue
|
|
1297
|
+
|
|
1298
|
+
Returns:
|
|
1299
|
+
Dictionary containing inward and outward issue links
|
|
1300
|
+
"""
|
|
1301
|
+
fields = "id,summary,linkType(name,localizedName),direction"
|
|
1302
|
+
response = self.client.get(f"issues/{issue_id}/links?fields={fields}")
|
|
1303
|
+
return response
|
|
1304
|
+
|
|
1305
|
+
def get_available_link_types(self) -> dict:
|
|
1306
|
+
"""
|
|
1307
|
+
Get all available issue link types.
|
|
1308
|
+
|
|
1309
|
+
Returns:
|
|
1310
|
+
List of available link types with their properties
|
|
1311
|
+
"""
|
|
1312
|
+
fields = "name,localizedName,sourceToTarget,targetToSource"
|
|
1313
|
+
response = self.client.get(f"issueLinkTypes?fields={fields}")
|
|
1314
|
+
return response
|
|
1315
|
+
|
|
1316
|
+
def _validate_custom_field_value(
|
|
1317
|
+
self,
|
|
1318
|
+
project_id: str,
|
|
1319
|
+
field_name: str,
|
|
1320
|
+
field_value: Any
|
|
1321
|
+
) -> bool:
|
|
1322
|
+
"""
|
|
1323
|
+
Internal method to validate a custom field value.
|
|
1324
|
+
|
|
1325
|
+
Args:
|
|
1326
|
+
project_id: The project ID
|
|
1327
|
+
field_name: The custom field name
|
|
1328
|
+
field_value: The value to validate
|
|
1329
|
+
|
|
1330
|
+
Returns:
|
|
1331
|
+
True if valid, False otherwise
|
|
1332
|
+
"""
|
|
1333
|
+
try:
|
|
1334
|
+
# Get field schema from project
|
|
1335
|
+
field_schema = self._get_custom_field_schema(project_id, field_name)
|
|
1336
|
+
if not field_schema:
|
|
1337
|
+
# If we can't get schema, assume valid (fallback)
|
|
1338
|
+
return True
|
|
1339
|
+
|
|
1340
|
+
field_type = field_schema.get("type", "")
|
|
1341
|
+
|
|
1342
|
+
# Type-specific validation
|
|
1343
|
+
if field_type in ["StateMachineBundle", "StateBundle"]:
|
|
1344
|
+
# State field - validate against available states
|
|
1345
|
+
allowed_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
1346
|
+
return str(field_value) in [str(v) for v in allowed_values]
|
|
1347
|
+
|
|
1348
|
+
elif field_type in ["EnumBundle", "OwnedBundle"]:
|
|
1349
|
+
# Enum field - validate against enum values
|
|
1350
|
+
allowed_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
1351
|
+
return str(field_value) in [str(v) for v in allowed_values]
|
|
1352
|
+
|
|
1353
|
+
elif field_type == "UserBundle":
|
|
1354
|
+
# User field - validate user exists
|
|
1355
|
+
return self._validate_user_exists(field_value)
|
|
1356
|
+
|
|
1357
|
+
elif field_type == "DateTimeBundle":
|
|
1358
|
+
# Date field - validate format
|
|
1359
|
+
return self._validate_date_format(field_value)
|
|
1360
|
+
|
|
1361
|
+
elif field_type in ["IntegerBundle", "FloatBundle"]:
|
|
1362
|
+
# Numeric field - validate numeric value
|
|
1363
|
+
return self._validate_numeric_value(field_value, field_type)
|
|
1364
|
+
|
|
1365
|
+
else:
|
|
1366
|
+
# String or unknown type - minimal validation
|
|
1367
|
+
return field_value is not None
|
|
1368
|
+
|
|
1369
|
+
except Exception:
|
|
1370
|
+
# If validation fails due to API errors, assume valid (fallback)
|
|
1371
|
+
return True
|
|
1372
|
+
|
|
1373
|
+
def _get_custom_field_schema(self, project_id: str, field_name: str) -> Optional[Dict[str, Any]]:
|
|
1374
|
+
"""Get custom field schema from project."""
|
|
1375
|
+
try:
|
|
1376
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields")
|
|
1377
|
+
for field in fields:
|
|
1378
|
+
if field.get("field", {}).get("name") == field_name:
|
|
1379
|
+
return field.get("field", {})
|
|
1380
|
+
return None
|
|
1381
|
+
except Exception:
|
|
1382
|
+
return None
|
|
1383
|
+
|
|
1384
|
+
def _get_custom_field_allowed_values(self, project_id: str, field_name: str) -> List[Any]:
|
|
1385
|
+
"""Get allowed values for enum/state fields."""
|
|
1386
|
+
try:
|
|
1387
|
+
field_schema = self._get_custom_field_schema(project_id, field_name)
|
|
1388
|
+
if not field_schema:
|
|
1389
|
+
return []
|
|
1390
|
+
|
|
1391
|
+
field_type = field_schema.get("fieldType", {}).get("valueType")
|
|
1392
|
+
if field_type in ["enum", "state"]:
|
|
1393
|
+
# Get bundle values
|
|
1394
|
+
bundle_id = field_schema.get("fieldType", {}).get("id")
|
|
1395
|
+
if bundle_id:
|
|
1396
|
+
bundle = self.client.get(f"admin/customFieldSettings/bundles/{field_type}/{bundle_id}")
|
|
1397
|
+
return [value.get("name", "") for value in bundle.get("values", [])]
|
|
1398
|
+
|
|
1399
|
+
return []
|
|
1400
|
+
except Exception:
|
|
1401
|
+
return []
|
|
1402
|
+
|
|
1403
|
+
def _validate_user_exists(self, user_value: str) -> bool:
|
|
1404
|
+
"""Validate that a user exists."""
|
|
1405
|
+
try:
|
|
1406
|
+
# Try to get user by login or ID
|
|
1407
|
+
self.client.get(f"users/{user_value}")
|
|
1408
|
+
return True
|
|
1409
|
+
except Exception:
|
|
1410
|
+
return False
|
|
1411
|
+
|
|
1412
|
+
def _validate_date_format(self, date_value: Any) -> bool:
|
|
1413
|
+
"""Validate date format."""
|
|
1414
|
+
if isinstance(date_value, int):
|
|
1415
|
+
# Unix timestamp
|
|
1416
|
+
return date_value > 0
|
|
1417
|
+
elif isinstance(date_value, str):
|
|
1418
|
+
# ISO date string or other formats
|
|
1419
|
+
import datetime
|
|
1420
|
+
try:
|
|
1421
|
+
datetime.datetime.fromisoformat(date_value.replace('Z', '+00:00'))
|
|
1422
|
+
return True
|
|
1423
|
+
except ValueError:
|
|
1424
|
+
return False
|
|
1425
|
+
return False
|
|
1426
|
+
|
|
1427
|
+
def _validate_numeric_value(self, value: Any, field_type: str) -> bool:
|
|
1428
|
+
"""Validate numeric value."""
|
|
1429
|
+
try:
|
|
1430
|
+
if field_type == "IntegerBundle":
|
|
1431
|
+
int(value)
|
|
1432
|
+
return True
|
|
1433
|
+
elif field_type == "FloatBundle":
|
|
1434
|
+
float(value)
|
|
1435
|
+
return True
|
|
1436
|
+
except (ValueError, TypeError):
|
|
1437
|
+
return False
|
|
1438
|
+
return False
|
|
1439
|
+
|
|
1440
|
+
def _format_custom_field_value(self, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
1441
|
+
"""Format custom field value for YouTrack API."""
|
|
1442
|
+
# YouTrack API expects different formats for different field types
|
|
1443
|
+
# The key issue: we need to use field ID, not name, and proper value format
|
|
1444
|
+
|
|
1445
|
+
if field_value is None:
|
|
1446
|
+
return {
|
|
1447
|
+
"name": field_name,
|
|
1448
|
+
"value": None
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
# For most string-based fields (State, Enum, etc.)
|
|
1452
|
+
if isinstance(field_value, str):
|
|
1453
|
+
return {
|
|
1454
|
+
"name": field_name,
|
|
1455
|
+
"value": {"name": field_value}
|
|
1456
|
+
}
|
|
1457
|
+
# For user fields, expect login format
|
|
1458
|
+
elif isinstance(field_value, dict) and "login" in field_value:
|
|
1459
|
+
return {
|
|
1460
|
+
"name": field_name,
|
|
1461
|
+
"value": {"login": field_value["login"]}
|
|
1462
|
+
}
|
|
1463
|
+
# For user fields as string (login)
|
|
1464
|
+
elif isinstance(field_value, str) and field_name.lower() in ["assignee", "reporter"]:
|
|
1465
|
+
return {
|
|
1466
|
+
"name": field_name,
|
|
1467
|
+
"value": {"login": field_value}
|
|
1468
|
+
}
|
|
1469
|
+
# For numeric fields (Period, Integer, Float)
|
|
1470
|
+
elif isinstance(field_value, (int, float)):
|
|
1471
|
+
return {
|
|
1472
|
+
"name": field_name,
|
|
1473
|
+
"value": field_value
|
|
1474
|
+
}
|
|
1475
|
+
# For period fields (time tracking)
|
|
1476
|
+
elif isinstance(field_value, str) and field_value.startswith("PT"):
|
|
1477
|
+
return {
|
|
1478
|
+
"name": field_name,
|
|
1479
|
+
"value": {"presentation": field_value}
|
|
1480
|
+
}
|
|
1481
|
+
# For complex objects (already formatted)
|
|
1482
|
+
elif isinstance(field_value, dict):
|
|
1483
|
+
return {
|
|
1484
|
+
"name": field_name,
|
|
1485
|
+
"value": field_value
|
|
1486
|
+
}
|
|
1487
|
+
# Default fallback
|
|
1488
|
+
else:
|
|
1489
|
+
return {
|
|
1490
|
+
"name": field_name,
|
|
1491
|
+
"value": {"name": str(field_value)}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
def _get_custom_field_id(self, project_id: str, field_name: str) -> Optional[str]:
|
|
1495
|
+
"""Get the field ID for a custom field by name."""
|
|
1496
|
+
try:
|
|
1497
|
+
# Use detailed fields query to get complete field information
|
|
1498
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
1499
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
1500
|
+
for field in fields:
|
|
1501
|
+
if field.get("field", {}).get("name") == field_name:
|
|
1502
|
+
return field.get("field", {}).get("id")
|
|
1503
|
+
return None
|
|
1504
|
+
except Exception as e:
|
|
1505
|
+
logger.warning(f"Error getting field ID for '{field_name}': {str(e)}")
|
|
1506
|
+
return None
|
|
1507
|
+
|
|
1508
|
+
def _get_field_type_info(self, project_id: str, field_id: str) -> Dict[str, Any]:
|
|
1509
|
+
"""Get field type information for proper $type formatting."""
|
|
1510
|
+
try:
|
|
1511
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
1512
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
1513
|
+
|
|
1514
|
+
for field in fields:
|
|
1515
|
+
if field.get("field", {}).get("id") == field_id:
|
|
1516
|
+
field_type = field.get("field", {}).get("fieldType", {})
|
|
1517
|
+
return {
|
|
1518
|
+
"bundle_type": field_type.get("$type", ""),
|
|
1519
|
+
"value_type": field_type.get("valueType", ""),
|
|
1520
|
+
"bundle_id": field_type.get("id")
|
|
1521
|
+
}
|
|
1522
|
+
return {}
|
|
1523
|
+
except Exception as e:
|
|
1524
|
+
logger.warning(f"Error getting field type info for field ID '{field_id}': {str(e)}")
|
|
1525
|
+
return {}
|
|
1526
|
+
|
|
1527
|
+
def _format_custom_field_value_with_id(self, field_id: str, field_value: Any, project_id: str = None) -> Dict[str, Any]:
|
|
1528
|
+
"""Format custom field value with field ID for YouTrack API."""
|
|
1529
|
+
# YouTrack API format: Complete IssueCustomField object with proper $type
|
|
1530
|
+
|
|
1531
|
+
# Get field type information to determine the correct IssueCustomField $type
|
|
1532
|
+
field_type_info = self._get_field_type_info(project_id, field_id) if project_id else {}
|
|
1533
|
+
bundle_type = field_type_info.get("bundle_type", "")
|
|
1534
|
+
value_type = field_type_info.get("value_type", "")
|
|
1535
|
+
|
|
1536
|
+
# Determine the IssueCustomField $type based on the bundle type and value type
|
|
1537
|
+
issue_field_type = self._get_issue_custom_field_type(bundle_type, value_type, field_id)
|
|
1538
|
+
|
|
1539
|
+
# Format the value based on type
|
|
1540
|
+
formatted_value = self._format_field_value(field_value, bundle_type, value_type, field_id)
|
|
1541
|
+
|
|
1542
|
+
return {
|
|
1543
|
+
"id": field_id,
|
|
1544
|
+
"value": formatted_value,
|
|
1545
|
+
"$type": issue_field_type
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
def _get_issue_custom_field_type(self, bundle_type: str, value_type: str, field_id: str) -> str:
|
|
1549
|
+
"""Determine the correct IssueCustomField $type based on field information."""
|
|
1550
|
+
|
|
1551
|
+
# Check for user fields first (special case)
|
|
1552
|
+
if any(keyword in field_id.lower() for keyword in ["assignee", "reporter", "user"]) or "UserBundle" in bundle_type:
|
|
1553
|
+
return "SingleUserIssueCustomField"
|
|
1554
|
+
|
|
1555
|
+
# Map based on value type and bundle type
|
|
1556
|
+
if value_type == "enum":
|
|
1557
|
+
return "SingleEnumIssueCustomField"
|
|
1558
|
+
elif value_type == "state":
|
|
1559
|
+
return "StateIssueCustomField"
|
|
1560
|
+
elif value_type == "user":
|
|
1561
|
+
return "SingleUserIssueCustomField"
|
|
1562
|
+
elif value_type == "period":
|
|
1563
|
+
return "PeriodIssueCustomField"
|
|
1564
|
+
elif value_type == "version":
|
|
1565
|
+
return "MultiVersionIssueCustomField"
|
|
1566
|
+
elif value_type in ["integer", "float", "string", "date"]:
|
|
1567
|
+
return "SimpleIssueCustomField"
|
|
1568
|
+
elif value_type == "text":
|
|
1569
|
+
return "TextIssueCustomField"
|
|
1570
|
+
elif "StateBundle" in bundle_type or "StateMachine" in bundle_type:
|
|
1571
|
+
return "StateIssueCustomField"
|
|
1572
|
+
elif "EnumBundle" in bundle_type:
|
|
1573
|
+
return "SingleEnumIssueCustomField"
|
|
1574
|
+
elif "UserBundle" in bundle_type:
|
|
1575
|
+
return "SingleUserIssueCustomField"
|
|
1576
|
+
elif "VersionBundle" in bundle_type:
|
|
1577
|
+
return "MultiVersionIssueCustomField"
|
|
1578
|
+
elif "PeriodBundle" in bundle_type:
|
|
1579
|
+
return "PeriodIssueCustomField"
|
|
1580
|
+
else:
|
|
1581
|
+
# Default fallback based on common field names
|
|
1582
|
+
if "priority" in field_id.lower() or "type" in field_id.lower():
|
|
1583
|
+
return "SingleEnumIssueCustomField"
|
|
1584
|
+
elif "state" in field_id.lower():
|
|
1585
|
+
return "StateIssueCustomField"
|
|
1586
|
+
else:
|
|
1587
|
+
return "SingleEnumIssueCustomField" # Most common type
|
|
1588
|
+
|
|
1589
|
+
def _format_field_value(self, field_value: Any, bundle_type: str, value_type: str, field_id: str) -> Any:
|
|
1590
|
+
"""Format the field value based on type information."""
|
|
1591
|
+
|
|
1592
|
+
if field_value is None:
|
|
1593
|
+
return None
|
|
1594
|
+
|
|
1595
|
+
# For user fields
|
|
1596
|
+
if any(keyword in field_id.lower() for keyword in ["assignee", "reporter", "user"]) or "UserBundle" in bundle_type or value_type == "user":
|
|
1597
|
+
if isinstance(field_value, str):
|
|
1598
|
+
return {
|
|
1599
|
+
"login": field_value,
|
|
1600
|
+
"$type": "User"
|
|
1601
|
+
}
|
|
1602
|
+
elif isinstance(field_value, dict) and "login" in field_value:
|
|
1603
|
+
return {
|
|
1604
|
+
"login": field_value["login"],
|
|
1605
|
+
"$type": "User"
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
# For period fields
|
|
1609
|
+
elif value_type == "period" or (isinstance(field_value, str) and field_value.startswith("PT")):
|
|
1610
|
+
return {
|
|
1611
|
+
"presentation": str(field_value),
|
|
1612
|
+
"$type": "PeriodValue"
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
# For state fields
|
|
1616
|
+
elif value_type == "state" or "StateBundle" in bundle_type or "StateMachine" in bundle_type or "state" in field_id.lower():
|
|
1617
|
+
return {
|
|
1618
|
+
"name": str(field_value),
|
|
1619
|
+
"$type": "StateBundleElement"
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
# For version/sprint fields
|
|
1623
|
+
elif value_type == "version" or "VersionBundle" in bundle_type:
|
|
1624
|
+
if isinstance(field_value, list):
|
|
1625
|
+
return [{"name": self._normalize_field_value(v), "$type": "VersionBundleElement"} for v in field_value]
|
|
1626
|
+
else:
|
|
1627
|
+
return [{"name": str(field_value), "$type": "VersionBundleElement"}]
|
|
1628
|
+
|
|
1629
|
+
# For enum fields
|
|
1630
|
+
elif value_type == "enum" or "EnumBundle" in bundle_type:
|
|
1631
|
+
return {
|
|
1632
|
+
"name": str(field_value),
|
|
1633
|
+
"$type": "EnumBundleElement"
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
# For numeric fields
|
|
1637
|
+
elif value_type in ["integer", "float"] and isinstance(field_value, (int, float)):
|
|
1638
|
+
return field_value
|
|
1639
|
+
|
|
1640
|
+
# For text/string fields
|
|
1641
|
+
elif value_type in ["string", "text"]:
|
|
1642
|
+
return str(field_value)
|
|
1643
|
+
|
|
1644
|
+
# For date fields
|
|
1645
|
+
elif value_type == "date":
|
|
1646
|
+
return field_value # Assume it's already properly formatted
|
|
1647
|
+
|
|
1648
|
+
# Default: treat as enum
|
|
1649
|
+
else:
|
|
1650
|
+
return {
|
|
1651
|
+
"name": str(field_value),
|
|
1652
|
+
"$type": "EnumBundleElement"
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
def _extract_custom_field_value(self, field_value_data: Any) -> Any:
|
|
1656
|
+
"""Extract readable value from YouTrack custom field value data."""
|
|
1657
|
+
if not field_value_data:
|
|
1658
|
+
return None
|
|
1659
|
+
|
|
1660
|
+
if isinstance(field_value_data, dict):
|
|
1661
|
+
# Try different value formats
|
|
1662
|
+
if "name" in field_value_data:
|
|
1663
|
+
return field_value_data["name"]
|
|
1664
|
+
elif "login" in field_value_data:
|
|
1665
|
+
return field_value_data["login"]
|
|
1666
|
+
elif "text" in field_value_data:
|
|
1667
|
+
return field_value_data["text"]
|
|
1668
|
+
elif "id" in field_value_data:
|
|
1669
|
+
return field_value_data["id"]
|
|
1670
|
+
|
|
1671
|
+
return field_value_data
|
|
1672
|
+
|
|
1673
|
+
def _normalize_field_value(self, field_value: Any) -> str:
|
|
1674
|
+
"""
|
|
1675
|
+
Normalize complex field value formats to simple string format.
|
|
1676
|
+
|
|
1677
|
+
Handles formats like:
|
|
1678
|
+
- Simple: "Task"
|
|
1679
|
+
- Complex: {"name": "Task"}
|
|
1680
|
+
- Complex: {"$type": "...", "name": "Task"}
|
|
1681
|
+
|
|
1682
|
+
Args:
|
|
1683
|
+
field_value: The field value in any format
|
|
1684
|
+
|
|
1685
|
+
Returns:
|
|
1686
|
+
Simple string value
|
|
1687
|
+
"""
|
|
1688
|
+
if isinstance(field_value, dict):
|
|
1689
|
+
# Extract name from complex object formats
|
|
1690
|
+
if "name" in field_value:
|
|
1691
|
+
return str(field_value["name"])
|
|
1692
|
+
elif "value" in field_value:
|
|
1693
|
+
# Handle nested value objects
|
|
1694
|
+
nested_value = field_value["value"]
|
|
1695
|
+
if isinstance(nested_value, dict) and "name" in nested_value:
|
|
1696
|
+
return str(nested_value["name"])
|
|
1697
|
+
else:
|
|
1698
|
+
return str(nested_value)
|
|
1699
|
+
else:
|
|
1700
|
+
# If no name field, convert entire object to string
|
|
1701
|
+
return str(field_value)
|
|
1702
|
+
else:
|
|
1703
|
+
# Already a simple value
|
|
1704
|
+
return str(field_value)
|
|
1705
|
+
|
|
1706
|
+
def _update_other_custom_fields(self, issue_id: str, custom_fields: Dict[str, Any], validate: bool, use_commands: bool) -> None:
|
|
1707
|
+
"""
|
|
1708
|
+
Update non-state custom fields, prioritizing direct field updates.
|
|
1709
|
+
|
|
1710
|
+
Based on successful testing, uses simple values where possible:
|
|
1711
|
+
- Strings: "Critical", "admin", "Bug"
|
|
1712
|
+
- NOT complex objects: {"name": "Critical", "id": "123"}
|
|
1713
|
+
|
|
1714
|
+
Args:
|
|
1715
|
+
issue_id: Issue identifier
|
|
1716
|
+
custom_fields: Dictionary of field names and values
|
|
1717
|
+
validate: Whether to validate field values
|
|
1718
|
+
use_commands: Whether to try command-based approach as fallback
|
|
1719
|
+
"""
|
|
1720
|
+
# Method 1: Direct field update approach (primary method)
|
|
1721
|
+
try:
|
|
1722
|
+
# Always get issue data to extract project ID for schema lookups
|
|
1723
|
+
issue_data = self.get_issue(issue_id)
|
|
1724
|
+
|
|
1725
|
+
# Validate fields if requested
|
|
1726
|
+
if validate:
|
|
1727
|
+
project_id = None
|
|
1728
|
+
if hasattr(issue_data, 'project') and issue_data.project:
|
|
1729
|
+
if isinstance(issue_data.project, dict):
|
|
1730
|
+
project_id = issue_data.project.get('id')
|
|
1731
|
+
else:
|
|
1732
|
+
project_id = getattr(issue_data.project, 'id', None)
|
|
1733
|
+
|
|
1734
|
+
if project_id:
|
|
1735
|
+
for field_name, field_value in custom_fields.items():
|
|
1736
|
+
is_valid = self._validate_custom_field_value(project_id, field_name, field_value)
|
|
1737
|
+
if not is_valid:
|
|
1738
|
+
raise YouTrackAPIError(f"Custom field validation failed for '{field_name}': '{field_value}' is not a valid value")
|
|
1739
|
+
else:
|
|
1740
|
+
logger.warning("Could not get project ID for validation, skipping validation")
|
|
1741
|
+
|
|
1742
|
+
# YouTrack requires proper object types with actual IDs for custom field updates
|
|
1743
|
+
# Try to get project ID for schema lookups (optional for enhanced object creation)
|
|
1744
|
+
project_id = None
|
|
1745
|
+
|
|
1746
|
+
if hasattr(issue_data, 'project') and issue_data.project:
|
|
1747
|
+
if isinstance(issue_data.project, dict):
|
|
1748
|
+
project_id = issue_data.project.get('id')
|
|
1749
|
+
else:
|
|
1750
|
+
project_id = getattr(issue_data.project, 'id', None)
|
|
1751
|
+
|
|
1752
|
+
# If we can't get project ID, fall back to simple $type approach
|
|
1753
|
+
if not project_id:
|
|
1754
|
+
logger.warning("Could not determine project ID for enhanced object creation, using simple $type approach")
|
|
1755
|
+
use_simple_approach = True
|
|
1756
|
+
else:
|
|
1757
|
+
use_simple_approach = False
|
|
1758
|
+
|
|
1759
|
+
update_data = {"customFields": []}
|
|
1760
|
+
|
|
1761
|
+
for field_name, raw_field_value in custom_fields.items():
|
|
1762
|
+
# Normalize complex object formats to simple strings first
|
|
1763
|
+
field_value = self._normalize_field_value(raw_field_value)
|
|
1764
|
+
|
|
1765
|
+
# Determine field type and construct proper object with actual ID
|
|
1766
|
+
if use_simple_approach:
|
|
1767
|
+
# Fallback to simple $type approach when project ID is not available
|
|
1768
|
+
if field_name.lower() in ['state']:
|
|
1769
|
+
field_type = "StateIssueCustomField"
|
|
1770
|
+
elif field_name.lower() in ['priority', 'type']:
|
|
1771
|
+
field_type = "SingleEnumIssueCustomField"
|
|
1772
|
+
elif field_name.lower() in ['assignee', 'reporter']:
|
|
1773
|
+
field_type = "SingleUserIssueCustomField"
|
|
1774
|
+
elif field_name.lower() in ['estimation', 'spent time']:
|
|
1775
|
+
field_type = "PeriodIssueCustomField"
|
|
1776
|
+
else:
|
|
1777
|
+
field_type = "SingleEnumIssueCustomField"
|
|
1778
|
+
|
|
1779
|
+
field_data = {
|
|
1780
|
+
"$type": field_type,
|
|
1781
|
+
"name": field_name,
|
|
1782
|
+
"value": field_value
|
|
1783
|
+
}
|
|
1784
|
+
else:
|
|
1785
|
+
# Enhanced approach with proper YouTrack objects and actual IDs
|
|
1786
|
+
# Handle Estimation with proper PeriodValue format
|
|
1787
|
+
if field_name.lower() in ['estimation']:
|
|
1788
|
+
# Estimation REQUIRES PeriodValue format, not simple strings
|
|
1789
|
+
field_data = self._create_period_field_object(field_name, field_value)
|
|
1790
|
+
elif field_name.lower() in ['state']:
|
|
1791
|
+
field_data = self._create_state_field_object(project_id, field_name, field_value)
|
|
1792
|
+
elif field_name.lower() in ['priority', 'type']:
|
|
1793
|
+
field_data = self._create_enum_field_object(project_id, field_name, field_value)
|
|
1794
|
+
elif field_name.lower() in ['assignee', 'reporter']:
|
|
1795
|
+
field_data = self._create_user_field_object(field_name, field_value)
|
|
1796
|
+
elif field_name.lower() in ['spent time']:
|
|
1797
|
+
field_data = self._create_period_field_object(field_name, field_value)
|
|
1798
|
+
elif field_name.lower() in self._VERSION_FIELD_NAMES:
|
|
1799
|
+
# Detect version/sprint fields by well-known name
|
|
1800
|
+
field_data = self._create_version_field_object(project_id, field_name, field_value, multi=True)
|
|
1801
|
+
else:
|
|
1802
|
+
# Query the schema to detect version/sprint fields
|
|
1803
|
+
value_type = self._get_field_value_type(project_id, field_name)
|
|
1804
|
+
if value_type == "version":
|
|
1805
|
+
field_data = self._create_version_field_object(project_id, field_name, field_value, multi=True)
|
|
1806
|
+
else:
|
|
1807
|
+
# Default to enum for unknown fields
|
|
1808
|
+
field_data = self._create_enum_field_object(project_id, field_name, field_value)
|
|
1809
|
+
|
|
1810
|
+
if field_data:
|
|
1811
|
+
update_data["customFields"].append(field_data)
|
|
1812
|
+
|
|
1813
|
+
logger.info(f"Updating custom fields for issue {issue_id} using proper YouTrack objects")
|
|
1814
|
+
logger.info(f"Update payload: {json.dumps(update_data, indent=2)}")
|
|
1815
|
+
self.client.post(f"issues/{issue_id}", data=update_data)
|
|
1816
|
+
logger.info(f"Direct field update succeeded for issue {issue_id}")
|
|
1817
|
+
|
|
1818
|
+
except Exception as direct_error:
|
|
1819
|
+
logger.warning(f"Direct field update failed: {direct_error}")
|
|
1820
|
+
|
|
1821
|
+
# Method 2: Fallback to command-based approach if enabled
|
|
1822
|
+
if use_commands:
|
|
1823
|
+
logger.info(f"Trying command-based approach as fallback for issue {issue_id}")
|
|
1824
|
+
try:
|
|
1825
|
+
self._apply_commands_update(issue_id, custom_fields)
|
|
1826
|
+
logger.info(f"Command-based update succeeded for issue {issue_id}")
|
|
1827
|
+
return
|
|
1828
|
+
except Exception as cmd_error:
|
|
1829
|
+
logger.warning(f"Command-based approach also failed: {cmd_error}")
|
|
1830
|
+
|
|
1831
|
+
# If both direct and command approaches fail, raise the original error with full details
|
|
1832
|
+
error_msg = f"Direct field update failed"
|
|
1833
|
+
if hasattr(direct_error, 'response') and hasattr(direct_error.response, 'json'):
|
|
1834
|
+
try:
|
|
1835
|
+
error_details = direct_error.response.json()
|
|
1836
|
+
if 'error_description' in error_details:
|
|
1837
|
+
error_msg += f": {error_details['error_description']}"
|
|
1838
|
+
elif 'error' in error_details:
|
|
1839
|
+
error_msg += f": {error_details['error']}"
|
|
1840
|
+
else:
|
|
1841
|
+
error_msg += f": {str(direct_error)}"
|
|
1842
|
+
except:
|
|
1843
|
+
error_msg += f": {str(direct_error)}"
|
|
1844
|
+
else:
|
|
1845
|
+
error_msg += f": {str(direct_error)}"
|
|
1846
|
+
|
|
1847
|
+
raise YouTrackAPIError(error_msg)
|
|
1848
|
+
|
|
1849
|
+
def _extract_project_id(self, issue_data) -> str:
|
|
1850
|
+
"""Extract project ID from issue data."""
|
|
1851
|
+
if hasattr(issue_data, 'project') and issue_data.project:
|
|
1852
|
+
if isinstance(issue_data.project, dict):
|
|
1853
|
+
return issue_data.project.get('id')
|
|
1854
|
+
else:
|
|
1855
|
+
return getattr(issue_data.project, 'id', None)
|
|
1856
|
+
return None
|
|
1857
|
+
|
|
1858
|
+
def _build_custom_fields_payload(self, custom_fields: Dict[str, Any], project_id: str = None) -> Dict[str, Any]:
|
|
1859
|
+
"""Build the custom fields payload with normalized field processing."""
|
|
1860
|
+
update_data = {"customFields": []}
|
|
1861
|
+
use_simple_approach = not project_id
|
|
1862
|
+
|
|
1863
|
+
if not project_id:
|
|
1864
|
+
logger.warning("Could not determine project ID for enhanced object creation, using simple $type approach")
|
|
1865
|
+
|
|
1866
|
+
for field_name, raw_field_value in custom_fields.items():
|
|
1867
|
+
# Normalize complex object formats to simple strings first
|
|
1868
|
+
field_value = self._normalize_field_value(raw_field_value)
|
|
1869
|
+
|
|
1870
|
+
# Create appropriate field object
|
|
1871
|
+
if use_simple_approach:
|
|
1872
|
+
field_data = self._create_simple_field_object(field_name, field_value)
|
|
1873
|
+
else:
|
|
1874
|
+
field_data = self._create_enhanced_field_object(project_id, field_name, field_value)
|
|
1875
|
+
|
|
1876
|
+
if field_data:
|
|
1877
|
+
update_data["customFields"].append(field_data)
|
|
1878
|
+
|
|
1879
|
+
return update_data
|
|
1880
|
+
|
|
1881
|
+
def _create_simple_field_object(self, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
1882
|
+
"""Create simple field object using basic $type approach."""
|
|
1883
|
+
field_name_lower = field_name.lower()
|
|
1884
|
+
|
|
1885
|
+
# Known version/sprint multi-value field names
|
|
1886
|
+
version_field_names = self._VERSION_FIELD_NAMES
|
|
1887
|
+
|
|
1888
|
+
if field_name_lower == 'state':
|
|
1889
|
+
field_type = "StateIssueCustomField"
|
|
1890
|
+
elif field_name_lower in ['priority', 'type']:
|
|
1891
|
+
field_type = "SingleEnumIssueCustomField"
|
|
1892
|
+
elif field_name_lower in ['assignee', 'reporter']:
|
|
1893
|
+
field_type = "SingleUserIssueCustomField"
|
|
1894
|
+
elif field_name_lower in ['estimation', 'spent time']:
|
|
1895
|
+
field_type = "PeriodIssueCustomField"
|
|
1896
|
+
elif field_name_lower in version_field_names:
|
|
1897
|
+
# Version/sprint fields require array value
|
|
1898
|
+
if isinstance(field_value, list):
|
|
1899
|
+
value_elements = [{"$type": "VersionBundleElement", "name": self._normalize_field_value(v)} for v in field_value]
|
|
1900
|
+
else:
|
|
1901
|
+
value_elements = [{"$type": "VersionBundleElement", "name": self._normalize_field_value(field_value)}]
|
|
1902
|
+
return {
|
|
1903
|
+
"$type": "MultiVersionIssueCustomField",
|
|
1904
|
+
"name": field_name,
|
|
1905
|
+
"value": value_elements
|
|
1906
|
+
}
|
|
1907
|
+
else:
|
|
1908
|
+
field_type = "SingleEnumIssueCustomField"
|
|
1909
|
+
|
|
1910
|
+
return {
|
|
1911
|
+
"$type": field_type,
|
|
1912
|
+
"name": field_name,
|
|
1913
|
+
"value": field_value
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
def _create_enhanced_field_object(self, project_id: str, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
1917
|
+
"""Create enhanced field object with proper YouTrack objects and actual IDs."""
|
|
1918
|
+
try:
|
|
1919
|
+
field_name_lower = field_name.lower()
|
|
1920
|
+
|
|
1921
|
+
if field_name_lower in ['estimation', 'spent time']:
|
|
1922
|
+
return self._create_period_field_object(field_name, field_value)
|
|
1923
|
+
elif field_name_lower == 'state':
|
|
1924
|
+
return self._create_state_field_object(project_id, field_name, field_value)
|
|
1925
|
+
elif field_name_lower in ['priority', 'type']:
|
|
1926
|
+
return self._create_enum_field_object(project_id, field_name, field_value)
|
|
1927
|
+
elif field_name_lower in ['assignee', 'reporter']:
|
|
1928
|
+
return self._create_user_field_object(field_name, field_value)
|
|
1929
|
+
else:
|
|
1930
|
+
# Query the schema to determine the actual field type
|
|
1931
|
+
value_type = self._get_field_value_type(project_id, field_name)
|
|
1932
|
+
if value_type == "version":
|
|
1933
|
+
return self._create_version_field_object(project_id, field_name, field_value)
|
|
1934
|
+
else:
|
|
1935
|
+
# Default to enum for unknown fields
|
|
1936
|
+
return self._create_enum_field_object(project_id, field_name, field_value)
|
|
1937
|
+
except Exception as e:
|
|
1938
|
+
logger.warning(f"Enhanced field creation failed for '{field_name}': {e}, falling back to simple approach")
|
|
1939
|
+
return self._create_simple_field_object(field_name, field_value)
|
|
1940
|
+
|
|
1941
|
+
def _create_enum_field_object(self, project_id: str, field_name: str, field_value: str) -> Dict[str, Any]:
|
|
1942
|
+
"""Create proper EnumBundleElement object with actual ID."""
|
|
1943
|
+
try:
|
|
1944
|
+
# Get allowed values to find the actual ID
|
|
1945
|
+
from youtrack_mcp.api.projects import ProjectsClient
|
|
1946
|
+
projects_client = ProjectsClient(self.client)
|
|
1947
|
+
allowed_values = projects_client.get_custom_field_allowed_values(project_id, field_name)
|
|
1948
|
+
|
|
1949
|
+
# Find the matching value by name (case-insensitive)
|
|
1950
|
+
value_id = None
|
|
1951
|
+
for value in allowed_values:
|
|
1952
|
+
if value.get('name', '').lower() == field_value.lower():
|
|
1953
|
+
value_id = value.get('id')
|
|
1954
|
+
break
|
|
1955
|
+
|
|
1956
|
+
if value_id:
|
|
1957
|
+
return {
|
|
1958
|
+
"$type": "SingleEnumIssueCustomField",
|
|
1959
|
+
"name": field_name,
|
|
1960
|
+
"value": {
|
|
1961
|
+
"$type": "EnumBundleElement",
|
|
1962
|
+
"id": value_id,
|
|
1963
|
+
"name": field_value
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
else:
|
|
1967
|
+
logger.warning(f"Could not find ID for enum value '{field_value}' in field '{field_name}', trying simple enum object")
|
|
1968
|
+
# Try simple enum object format if ID lookup fails
|
|
1969
|
+
return {
|
|
1970
|
+
"$type": "SingleEnumIssueCustomField",
|
|
1971
|
+
"name": field_name,
|
|
1972
|
+
"value": {
|
|
1973
|
+
"$type": "EnumBundleElement",
|
|
1974
|
+
"name": field_value
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
except Exception as e:
|
|
1978
|
+
logger.warning(f"Error creating enum field object for '{field_name}': {e}, trying simple enum object")
|
|
1979
|
+
# Fallback to simple enum object (no ID)
|
|
1980
|
+
return {
|
|
1981
|
+
"$type": "SingleEnumIssueCustomField",
|
|
1982
|
+
"name": field_name,
|
|
1983
|
+
"value": {
|
|
1984
|
+
"$type": "EnumBundleElement",
|
|
1985
|
+
"name": field_value
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
def _create_state_field_object(self, project_id: str, field_name: str, field_value: str) -> Dict[str, Any]:
|
|
1990
|
+
"""Create proper StateBundleElement object with actual ID."""
|
|
1991
|
+
try:
|
|
1992
|
+
# Get allowed values to find the actual ID
|
|
1993
|
+
from youtrack_mcp.api.projects import ProjectsClient
|
|
1994
|
+
projects_client = ProjectsClient(self.client)
|
|
1995
|
+
allowed_values = projects_client.get_custom_field_allowed_values(project_id, field_name)
|
|
1996
|
+
|
|
1997
|
+
# Find the matching state by name (case-insensitive)
|
|
1998
|
+
state_id = None
|
|
1999
|
+
for value in allowed_values:
|
|
2000
|
+
if value.get('name', '').lower() == field_value.lower():
|
|
2001
|
+
state_id = value.get('id')
|
|
2002
|
+
break
|
|
2003
|
+
|
|
2004
|
+
if state_id:
|
|
2005
|
+
return {
|
|
2006
|
+
"$type": "StateIssueCustomField",
|
|
2007
|
+
"name": field_name,
|
|
2008
|
+
"value": {
|
|
2009
|
+
"$type": "StateBundleElement",
|
|
2010
|
+
"id": state_id,
|
|
2011
|
+
"name": field_value
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
else:
|
|
2015
|
+
logger.warning(f"Could not find ID for state value '{field_value}' in field '{field_name}', using simple value")
|
|
2016
|
+
return {
|
|
2017
|
+
"$type": "StateIssueCustomField",
|
|
2018
|
+
"name": field_name,
|
|
2019
|
+
"value": field_value
|
|
2020
|
+
}
|
|
2021
|
+
except Exception as e:
|
|
2022
|
+
logger.warning(f"Error creating state field object for '{field_name}': {e}, using simple value")
|
|
2023
|
+
return {
|
|
2024
|
+
"$type": "StateIssueCustomField",
|
|
2025
|
+
"name": field_name,
|
|
2026
|
+
"value": field_value
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
def _create_user_field_object(self, field_name: str, field_value: str) -> Dict[str, Any]:
|
|
2030
|
+
"""Create proper User object with actual ID."""
|
|
2031
|
+
try:
|
|
2032
|
+
# Get user ID by login
|
|
2033
|
+
from youtrack_mcp.api.users import UsersClient
|
|
2034
|
+
users_client = UsersClient(self.client)
|
|
2035
|
+
user_data = users_client.get_user(field_value)
|
|
2036
|
+
|
|
2037
|
+
if user_data and hasattr(user_data, 'id') and user_data.id:
|
|
2038
|
+
return {
|
|
2039
|
+
"$type": "SingleUserIssueCustomField",
|
|
2040
|
+
"name": field_name,
|
|
2041
|
+
"value": {
|
|
2042
|
+
"$type": "User",
|
|
2043
|
+
"id": user_data.id,
|
|
2044
|
+
"login": field_value
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
else:
|
|
2048
|
+
logger.warning(f"Could not find user ID for login '{field_value}', using simple value")
|
|
2049
|
+
return {
|
|
2050
|
+
"$type": "SingleUserIssueCustomField",
|
|
2051
|
+
"name": field_name,
|
|
2052
|
+
"value": field_value
|
|
2053
|
+
}
|
|
2054
|
+
except Exception as e:
|
|
2055
|
+
logger.warning(f"Error creating user field object for '{field_name}': {e}, using simple value")
|
|
2056
|
+
return {
|
|
2057
|
+
"$type": "SingleUserIssueCustomField",
|
|
2058
|
+
"name": field_name,
|
|
2059
|
+
"value": field_value
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
def _create_period_field_object(self, field_name: str, field_value: str) -> Dict[str, Any]:
|
|
2063
|
+
"""Create proper period field object with PeriodValue format."""
|
|
2064
|
+
try:
|
|
2065
|
+
# Convert simple time strings to proper PeriodValue format
|
|
2066
|
+
# Examples: "4h" -> 240 minutes, "30m" -> 30 minutes, "2h 30m" -> 150 minutes
|
|
2067
|
+
|
|
2068
|
+
minutes = self._parse_time_to_minutes(field_value)
|
|
2069
|
+
|
|
2070
|
+
if minutes is not None:
|
|
2071
|
+
return {
|
|
2072
|
+
"$type": "PeriodIssueCustomField",
|
|
2073
|
+
"name": field_name,
|
|
2074
|
+
"value": {
|
|
2075
|
+
"$type": "PeriodValue",
|
|
2076
|
+
"minutes": minutes
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
else:
|
|
2080
|
+
# Fallback to simple value if parsing fails
|
|
2081
|
+
logger.warning(f"Could not parse period value '{field_value}' for field '{field_name}', using simple value")
|
|
2082
|
+
return {
|
|
2083
|
+
"$type": "PeriodIssueCustomField",
|
|
2084
|
+
"name": field_name,
|
|
2085
|
+
"value": field_value
|
|
2086
|
+
}
|
|
2087
|
+
except Exception as e:
|
|
2088
|
+
logger.warning(f"Error creating period field object for '{field_name}': {e}, using simple value")
|
|
2089
|
+
return {
|
|
2090
|
+
"$type": "PeriodIssueCustomField",
|
|
2091
|
+
"name": field_name,
|
|
2092
|
+
"value": field_value
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
def _parse_time_to_minutes(self, time_str: str) -> Optional[int]:
|
|
2096
|
+
"""Parse time string to minutes for PeriodValue."""
|
|
2097
|
+
try:
|
|
2098
|
+
time_str = time_str.strip().lower()
|
|
2099
|
+
total_minutes = 0
|
|
2100
|
+
|
|
2101
|
+
# Handle formats like "4h", "30m", "2h 30m", "1h30m"
|
|
2102
|
+
|
|
2103
|
+
# Extract hours
|
|
2104
|
+
hours_match = re.search(r'(\d+)\s*h', time_str)
|
|
2105
|
+
if hours_match:
|
|
2106
|
+
total_minutes += int(hours_match.group(1)) * 60
|
|
2107
|
+
|
|
2108
|
+
# Extract minutes
|
|
2109
|
+
minutes_match = re.search(r'(\d+)\s*m', time_str)
|
|
2110
|
+
if minutes_match:
|
|
2111
|
+
total_minutes += int(minutes_match.group(1))
|
|
2112
|
+
|
|
2113
|
+
# If no h or m found, assume it's just minutes
|
|
2114
|
+
if not hours_match and not minutes_match:
|
|
2115
|
+
# Try to parse as plain number (minutes)
|
|
2116
|
+
if time_str.isdigit():
|
|
2117
|
+
total_minutes = int(time_str)
|
|
2118
|
+
else:
|
|
2119
|
+
return None
|
|
2120
|
+
|
|
2121
|
+
return total_minutes if total_minutes > 0 else None
|
|
2122
|
+
|
|
2123
|
+
except Exception as e:
|
|
2124
|
+
logger.debug(f"Failed to parse time string '{time_str}': {e}")
|
|
2125
|
+
return None
|
|
2126
|
+
|
|
2127
|
+
def _apply_commands_update(self, issue_id: str, custom_fields: Dict[str, Any]) -> None:
|
|
2128
|
+
"""
|
|
2129
|
+
Apply custom field updates using the command-based approach.
|
|
2130
|
+
This method is a fallback and might not be as reliable as direct field updates.
|
|
2131
|
+
"""
|
|
2132
|
+
try:
|
|
2133
|
+
command_data = {"query": "updateCustomFields"}
|
|
2134
|
+
for field_name, field_value in custom_fields.items():
|
|
2135
|
+
command_data["query"] += f" {field_name} {field_value}"
|
|
2136
|
+
|
|
2137
|
+
command_data["issues"] = [{"id": issue_id}]
|
|
2138
|
+
|
|
2139
|
+
logger.info(f"Applying command-based update for issue {issue_id} with query: {command_data['query']}")
|
|
2140
|
+
self.client.post("commands", data=command_data)
|
|
2141
|
+
logger.info(f"Command-based update succeeded for issue {issue_id}")
|
|
2142
|
+
except Exception as e:
|
|
2143
|
+
logger.warning(f"Command-based update failed: {e}")
|
|
2144
|
+
raise
|
|
2145
|
+
|
|
2146
|
+
def _get_field_value_type(self, project_id: str, field_name: str) -> str:
|
|
2147
|
+
"""Get the valueType for a custom field from the project schema."""
|
|
2148
|
+
try:
|
|
2149
|
+
fields_query = "field(id,name,fieldType($type,valueType,id))"
|
|
2150
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
2151
|
+
for field in fields:
|
|
2152
|
+
if field.get("field", {}).get("name", "").lower() == field_name.lower():
|
|
2153
|
+
field_type = field.get("field", {}).get("fieldType", {})
|
|
2154
|
+
return field_type.get("valueType", "")
|
|
2155
|
+
return ""
|
|
2156
|
+
except Exception as e:
|
|
2157
|
+
logger.warning(f"Error getting field value type for '{field_name}': {e}")
|
|
2158
|
+
return ""
|
|
2159
|
+
|
|
2160
|
+
def _create_version_field_object(self, project_id: str, field_name: str, field_value: Any, multi: bool = True) -> Dict[str, Any]:
|
|
2161
|
+
"""Create proper VersionBundleElement object for version/sprint fields.
|
|
2162
|
+
|
|
2163
|
+
Args:
|
|
2164
|
+
project_id: Project used to resolve version IDs
|
|
2165
|
+
field_name: The custom field name
|
|
2166
|
+
field_value: A single value or a list of values
|
|
2167
|
+
multi: When True, build a MultiVersionIssueCustomField (list value);
|
|
2168
|
+
when False, build a SingleVersionIssueCustomField (single value)
|
|
2169
|
+
"""
|
|
2170
|
+
def _wrap(value_elements):
|
|
2171
|
+
if multi:
|
|
2172
|
+
return {
|
|
2173
|
+
"$type": "MultiVersionIssueCustomField",
|
|
2174
|
+
"name": field_name,
|
|
2175
|
+
"value": value_elements,
|
|
2176
|
+
}
|
|
2177
|
+
return {
|
|
2178
|
+
"$type": "SingleVersionIssueCustomField",
|
|
2179
|
+
"name": field_name,
|
|
2180
|
+
"value": value_elements[0] if value_elements else None,
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
try:
|
|
2184
|
+
# Accept either a string (single value) or list of values
|
|
2185
|
+
if isinstance(field_value, list):
|
|
2186
|
+
value_names = [self._normalize_field_value(v) for v in field_value]
|
|
2187
|
+
else:
|
|
2188
|
+
value_names = [self._normalize_field_value(field_value)]
|
|
2189
|
+
|
|
2190
|
+
# Try to get version IDs from the project
|
|
2191
|
+
from youtrack_mcp.api.projects import ProjectsClient
|
|
2192
|
+
projects_client = ProjectsClient(self.client)
|
|
2193
|
+
allowed_values = projects_client.get_custom_field_allowed_values(project_id, field_name)
|
|
2194
|
+
|
|
2195
|
+
value_elements = []
|
|
2196
|
+
for name in value_names:
|
|
2197
|
+
element = {"$type": "VersionBundleElement", "name": name}
|
|
2198
|
+
for v in allowed_values:
|
|
2199
|
+
if v.get('name', '').lower() == name.lower():
|
|
2200
|
+
element["id"] = v.get('id')
|
|
2201
|
+
# Prefer the canonical name from the project schema
|
|
2202
|
+
element["name"] = v.get('name', name)
|
|
2203
|
+
break
|
|
2204
|
+
value_elements.append(element)
|
|
2205
|
+
|
|
2206
|
+
return _wrap(value_elements)
|
|
2207
|
+
except Exception as e:
|
|
2208
|
+
logger.warning(f"Error creating version field object for '{field_name}': {e}, using minimal format")
|
|
2209
|
+
if isinstance(field_value, list):
|
|
2210
|
+
value_elements = [{"$type": "VersionBundleElement", "name": self._normalize_field_value(v)} for v in field_value]
|
|
2211
|
+
else:
|
|
2212
|
+
value_elements = [{"$type": "VersionBundleElement", "name": self._normalize_field_value(field_value)}]
|
|
2213
|
+
return _wrap(value_elements)
|
|
2214
|
+
|
|
2215
|
+
def get_issue_custom_fields(self, issue_id: str) -> Dict[str, Any]:
|
|
2216
|
+
"""
|
|
2217
|
+
Get all custom fields for a specific issue.
|
|
2218
|
+
|
|
2219
|
+
Args:
|
|
2220
|
+
issue_id: The issue ID or readable ID
|
|
2221
|
+
|
|
2222
|
+
Returns:
|
|
2223
|
+
Dictionary of custom field name-value pairs
|
|
2224
|
+
"""
|
|
2225
|
+
fields = "customFields(id,name,value($type,name,text,id,login))"
|
|
2226
|
+
response = self.client.get(f"issues/{issue_id}?fields={fields}")
|
|
2227
|
+
|
|
2228
|
+
custom_fields = {}
|
|
2229
|
+
if "customFields" in response:
|
|
2230
|
+
for field in response["customFields"]:
|
|
2231
|
+
field_name = field.get("name", "")
|
|
2232
|
+
field_value = self._extract_custom_field_value(field.get("value"))
|
|
2233
|
+
custom_fields[field_name] = field_value
|
|
2234
|
+
|
|
2235
|
+
return custom_fields
|
|
2236
|
+
|
|
2237
|
+
def validate_custom_field_value(
|
|
2238
|
+
self,
|
|
2239
|
+
project_id: str,
|
|
2240
|
+
field_name: str,
|
|
2241
|
+
field_value: Any
|
|
2242
|
+
) -> Dict[str, Any]:
|
|
2243
|
+
"""
|
|
2244
|
+
Validate a custom field value against project schema.
|
|
2245
|
+
|
|
2246
|
+
Args:
|
|
2247
|
+
project_id: The project ID
|
|
2248
|
+
field_name: The custom field name
|
|
2249
|
+
field_value: The value to validate
|
|
2250
|
+
|
|
2251
|
+
Returns:
|
|
2252
|
+
Dictionary with validation result and details
|
|
2253
|
+
"""
|
|
2254
|
+
try:
|
|
2255
|
+
is_valid = self._validate_custom_field_value(project_id, field_name, field_value)
|
|
2256
|
+
|
|
2257
|
+
if is_valid:
|
|
2258
|
+
return {
|
|
2259
|
+
"valid": True,
|
|
2260
|
+
"field": field_name,
|
|
2261
|
+
"value": field_value,
|
|
2262
|
+
"message": "Valid"
|
|
2263
|
+
}
|
|
2264
|
+
else:
|
|
2265
|
+
# Get available values for better error messages
|
|
2266
|
+
available_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
2267
|
+
suggestion = f"Available values: {', '.join(map(str, available_values))}" if available_values else "Check field configuration"
|
|
2268
|
+
|
|
2269
|
+
return {
|
|
2270
|
+
"valid": False,
|
|
2271
|
+
"field": field_name,
|
|
2272
|
+
"value": field_value,
|
|
2273
|
+
"error": f"Invalid value '{field_value}' for field '{field_name}'",
|
|
2274
|
+
"suggestion": suggestion
|
|
2275
|
+
}
|
|
2276
|
+
except Exception as e:
|
|
2277
|
+
return {
|
|
2278
|
+
"valid": False,
|
|
2279
|
+
"field": field_name,
|
|
2280
|
+
"value": field_value,
|
|
2281
|
+
"error": f"Validation error: {str(e)}",
|
|
2282
|
+
"suggestion": "Check field name and project configuration"
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
def batch_update_custom_fields(
|
|
2286
|
+
self,
|
|
2287
|
+
updates: List[Dict[str, Any]]
|
|
2288
|
+
) -> List[Dict[str, Any]]:
|
|
2289
|
+
"""
|
|
2290
|
+
Update custom fields for multiple issues in a single operation.
|
|
2291
|
+
|
|
2292
|
+
Args:
|
|
2293
|
+
updates: List of update dictionaries with format:
|
|
2294
|
+
[{"issue_id": "DEMO-123", "fields": {"Priority": "High"}}]
|
|
2295
|
+
|
|
2296
|
+
Returns:
|
|
2297
|
+
List of update results with success/error status
|
|
2298
|
+
"""
|
|
2299
|
+
results = []
|
|
2300
|
+
|
|
2301
|
+
for update in updates:
|
|
2302
|
+
issue_id = update.get("issue_id")
|
|
2303
|
+
fields = update.get("fields", {})
|
|
2304
|
+
|
|
2305
|
+
if not issue_id:
|
|
2306
|
+
results.append({
|
|
2307
|
+
"issue_id": None,
|
|
2308
|
+
"status": "error",
|
|
2309
|
+
"error": "Missing issue_id in update"
|
|
2310
|
+
})
|
|
2311
|
+
continue
|
|
2312
|
+
|
|
2313
|
+
if not fields:
|
|
2314
|
+
results.append({
|
|
2315
|
+
"issue_id": issue_id,
|
|
2316
|
+
"status": "skipped",
|
|
2317
|
+
"message": "No fields to update"
|
|
2318
|
+
})
|
|
2319
|
+
continue
|
|
2320
|
+
|
|
2321
|
+
try:
|
|
2322
|
+
updated_issue = self.update_issue_custom_fields(
|
|
2323
|
+
issue_id=issue_id,
|
|
2324
|
+
custom_fields=fields,
|
|
2325
|
+
validate=update.get("validate", True)
|
|
2326
|
+
)
|
|
2327
|
+
|
|
2328
|
+
results.append({
|
|
2329
|
+
"issue_id": issue_id,
|
|
2330
|
+
"status": "success",
|
|
2331
|
+
"updated_fields": list(fields.keys()),
|
|
2332
|
+
"issue_data": updated_issue.model_dump() if hasattr(updated_issue, 'model_dump') else updated_issue
|
|
2333
|
+
})
|
|
2334
|
+
|
|
2335
|
+
except Exception as e:
|
|
2336
|
+
results.append({
|
|
2337
|
+
"issue_id": issue_id,
|
|
2338
|
+
"status": "error",
|
|
2339
|
+
"error": str(e),
|
|
2340
|
+
"attempted_fields": list(fields.keys())
|
|
2341
|
+
})
|
|
2342
|
+
|
|
2343
|
+
return results
|
|
2344
|
+
|
|
2345
|
+
def search_issues(self, query: str, limit: int = 10) -> List[Issue]:
|
|
2346
|
+
"""
|
|
2347
|
+
Search for issues using YouTrack query language.
|
|
2348
|
+
|
|
2349
|
+
Args:
|
|
2350
|
+
query: The search query
|
|
2351
|
+
limit: Maximum number of issues to return
|
|
2352
|
+
|
|
2353
|
+
Returns:
|
|
2354
|
+
List of matching issues
|
|
2355
|
+
"""
|
|
2356
|
+
# Request additional fields to ensure we get summary
|
|
2357
|
+
fields = "id,idReadable,summary,description,created,updated,project,reporter,assignee,customFields"
|
|
2358
|
+
params = {"query": query, "$top": limit, "fields": fields}
|
|
2359
|
+
response = self.client.get("issues", params=params)
|
|
2360
|
+
|
|
2361
|
+
issues = []
|
|
2362
|
+
if isinstance(response, list):
|
|
2363
|
+
for issue_data in response:
|
|
2364
|
+
try:
|
|
2365
|
+
issues.append(Issue.model_validate(issue_data))
|
|
2366
|
+
except Exception as e:
|
|
2367
|
+
logger.warning(f"Error validating issue: {e}")
|
|
2368
|
+
# Create a basic issue with id
|
|
2369
|
+
issue_id = issue_data.get(
|
|
2370
|
+
"id", str(issue_data.get("created", "unknown"))
|
|
2371
|
+
)
|
|
2372
|
+
issues.append(
|
|
2373
|
+
Issue(
|
|
2374
|
+
id=issue_id,
|
|
2375
|
+
summary=issue_data.get(
|
|
2376
|
+
"summary", f"Issue {issue_id}"
|
|
2377
|
+
),
|
|
2378
|
+
)
|
|
2379
|
+
)
|
|
2380
|
+
|
|
2381
|
+
return issues
|
|
2382
|
+
|
|
2383
|
+
def add_comment(self, issue_id: str, text: str) -> Dict[str, Any]:
|
|
2384
|
+
"""
|
|
2385
|
+
Add a comment to an issue.
|
|
2386
|
+
|
|
2387
|
+
Args:
|
|
2388
|
+
issue_id: The issue ID or readable ID
|
|
2389
|
+
text: The comment text
|
|
2390
|
+
|
|
2391
|
+
Returns:
|
|
2392
|
+
The created comment data
|
|
2393
|
+
"""
|
|
2394
|
+
data = {"text": text}
|
|
2395
|
+
return self.client.post(f"issues/{issue_id}/comments", data=data)
|
|
2396
|
+
|
|
2397
|
+
def get_attachment_content(
|
|
2398
|
+
self, issue_id: str, attachment_id: str
|
|
2399
|
+
) -> bytes:
|
|
2400
|
+
"""
|
|
2401
|
+
Get the content of an attachment with file size validation.
|
|
2402
|
+
|
|
2403
|
+
Args:
|
|
2404
|
+
issue_id: The issue ID or readable ID
|
|
2405
|
+
attachment_id: The attachment ID
|
|
2406
|
+
|
|
2407
|
+
Returns:
|
|
2408
|
+
The attachment content as bytes
|
|
2409
|
+
|
|
2410
|
+
Raises:
|
|
2411
|
+
ValueError: If attachment not found or file too large
|
|
2412
|
+
YouTrackAPIError: If API request fails
|
|
2413
|
+
"""
|
|
2414
|
+
# First, get the attachment metadata to get the URL and size
|
|
2415
|
+
issue_response = self.client.get(
|
|
2416
|
+
f"issues/{issue_id}?fields=attachments(id,url,size,name,mimeType)"
|
|
2417
|
+
)
|
|
2418
|
+
|
|
2419
|
+
# Find the attachment with the matching ID
|
|
2420
|
+
attachment_info = None
|
|
2421
|
+
if "attachments" in issue_response:
|
|
2422
|
+
for attachment in issue_response["attachments"]:
|
|
2423
|
+
if attachment.get("id") == attachment_id:
|
|
2424
|
+
attachment_info = attachment
|
|
2425
|
+
break
|
|
2426
|
+
|
|
2427
|
+
if not attachment_info:
|
|
2428
|
+
raise ValueError(
|
|
2429
|
+
f"Attachment {attachment_id} not found in issue {issue_id}"
|
|
2430
|
+
)
|
|
2431
|
+
|
|
2432
|
+
# Check file size limit for base64 encoding
|
|
2433
|
+
# Base64 encoding increases size by ~33%, so for 1MB base64 limit, max original size is ~750KB
|
|
2434
|
+
MAX_ORIGINAL_SIZE = 750 * 1024 # 750KB original file
|
|
2435
|
+
MAX_BASE64_SIZE = (
|
|
2436
|
+
1024 * 1024
|
|
2437
|
+
) # 1MB after base64 encoding (Claude Desktop limit)
|
|
2438
|
+
|
|
2439
|
+
file_size = attachment_info.get("size", 0)
|
|
2440
|
+
filename = attachment_info.get("name", attachment_id)
|
|
2441
|
+
mime_type = attachment_info.get("mimeType", "unknown")
|
|
2442
|
+
|
|
2443
|
+
if file_size > MAX_ORIGINAL_SIZE:
|
|
2444
|
+
# Calculate what the base64 size would be
|
|
2445
|
+
estimated_base64_size = int(file_size * 1.33)
|
|
2446
|
+
raise ValueError(
|
|
2447
|
+
f"Attachment '{filename}' ({mime_type}) is too large "
|
|
2448
|
+
f"({file_size:,} bytes → ~{estimated_base64_size:,} bytes after base64 encoding). "
|
|
2449
|
+
f"Maximum allowed: {MAX_ORIGINAL_SIZE:,} bytes original (~{MAX_BASE64_SIZE:,} bytes base64)."
|
|
2450
|
+
)
|
|
2451
|
+
|
|
2452
|
+
attachment_url = attachment_info.get("url")
|
|
2453
|
+
if not attachment_url:
|
|
2454
|
+
raise ValueError(
|
|
2455
|
+
f"No download URL found for attachment {attachment_id}"
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
# The URL in the attachment data is relative to the base URL
|
|
2459
|
+
if attachment_url.startswith("/"):
|
|
2460
|
+
attachment_url = attachment_url[1:] # Remove leading slash
|
|
2461
|
+
|
|
2462
|
+
# Construct the full URL
|
|
2463
|
+
base_url = self.client.base_url
|
|
2464
|
+
if base_url.endswith("/api"):
|
|
2465
|
+
base_url = base_url[:-4] # Remove '/api' suffix
|
|
2466
|
+
|
|
2467
|
+
full_url = f"{base_url}/{attachment_url}"
|
|
2468
|
+
|
|
2469
|
+
# Make the request to get the attachment content
|
|
2470
|
+
from youtrack_mcp.api.client import YouTrackAPIError
|
|
2471
|
+
|
|
2472
|
+
response = self.client.session.get(full_url)
|
|
2473
|
+
|
|
2474
|
+
# Check for errors
|
|
2475
|
+
if response.status_code >= 400:
|
|
2476
|
+
error_msg = (
|
|
2477
|
+
f"Error getting attachment content: {response.status_code}"
|
|
2478
|
+
)
|
|
2479
|
+
raise YouTrackAPIError(error_msg, response.status_code, response)
|
|
2480
|
+
|
|
2481
|
+
# Double-check the actual content size
|
|
2482
|
+
content_length = len(response.content)
|
|
2483
|
+
if content_length > MAX_ORIGINAL_SIZE:
|
|
2484
|
+
estimated_base64_size = int(content_length * 1.33)
|
|
2485
|
+
raise ValueError(
|
|
2486
|
+
f"Downloaded content size ({content_length:,} bytes → ~{estimated_base64_size:,} bytes base64) "
|
|
2487
|
+
f"exceeds maximum allowed size ({MAX_ORIGINAL_SIZE:,} bytes original). "
|
|
2488
|
+
f"The file may have been modified since metadata was fetched."
|
|
2489
|
+
)
|
|
2490
|
+
|
|
2491
|
+
# Return the binary content
|
|
2492
|
+
return response.content
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
def _get_internal_id(self, issue_id: str) -> str:
|
|
2496
|
+
"""Convert issue ID to internal format if needed."""
|
|
2497
|
+
try:
|
|
2498
|
+
internal_id = self.client.get(f"issues/{issue_id}?fields=id")["id"]
|
|
2499
|
+
return internal_id
|
|
2500
|
+
except Exception:
|
|
2501
|
+
# If that fails, assume the issue_id is already internal
|
|
2502
|
+
return issue_id
|
|
2503
|
+
|
|
2504
|
+
def _get_readable_id(self, issue_id: str) -> str:
|
|
2505
|
+
"""
|
|
2506
|
+
Convert an internal issue ID (like 3-37) to readable ID (like DEMO-37).
|
|
2507
|
+
If it's already a readable ID, return as-is.
|
|
2508
|
+
|
|
2509
|
+
Args:
|
|
2510
|
+
issue_id: Issue ID (internal like '3-41' or readable like 'DEMO-123')
|
|
2511
|
+
|
|
2512
|
+
Returns:
|
|
2513
|
+
Readable project ID (like 'DEMO-41')
|
|
2514
|
+
"""
|
|
2515
|
+
try:
|
|
2516
|
+
# If it doesn't look like an internal ID, return as-is
|
|
2517
|
+
if not ("-" in issue_id and issue_id.replace("-", "").isdigit()):
|
|
2518
|
+
return issue_id
|
|
2519
|
+
|
|
2520
|
+
# Fetch the issue to get its readable ID
|
|
2521
|
+
issue = self.client.get(f"issues/{issue_id}?fields=idReadable")
|
|
2522
|
+
return issue.get("idReadable", issue_id)
|
|
2523
|
+
except Exception:
|
|
2524
|
+
# If we can't get the readable ID, return the original
|
|
2525
|
+
return issue_id
|
|
2526
|
+
|
|
2527
|
+
def link_issues(
|
|
2528
|
+
self, source_issue_id: str, target_issue_id: str, link_type: str
|
|
2529
|
+
) -> dict:
|
|
2530
|
+
"""
|
|
2531
|
+
Link two issues together using Commands API.
|
|
2532
|
+
|
|
2533
|
+
Args:
|
|
2534
|
+
source_issue_id: The ID of the source issue
|
|
2535
|
+
target_issue_id: The ID of the target issue
|
|
2536
|
+
link_type: The type of link (e.g., 'Relates', 'Duplicates', 'Depends on')
|
|
2537
|
+
|
|
2538
|
+
Returns:
|
|
2539
|
+
The created link data
|
|
2540
|
+
"""
|
|
2541
|
+
# Map link types to correct YouTrack command syntax
|
|
2542
|
+
link_command_map = {
|
|
2543
|
+
"relates": "relates to",
|
|
2544
|
+
"depends on": "depends on",
|
|
2545
|
+
"duplicates": "duplicates",
|
|
2546
|
+
"is duplicated by": "is duplicated by",
|
|
2547
|
+
"is required for": "is required for",
|
|
2548
|
+
"parent for": "parent for",
|
|
2549
|
+
"subtask": "subtask of",
|
|
2550
|
+
"subtask of": "subtask of",
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
# Get internal IDs for both issues (Commands API requires internal IDs in issues array)
|
|
2554
|
+
source_internal_id = self._get_internal_id(source_issue_id)
|
|
2555
|
+
target_internal_id = self._get_internal_id(target_issue_id)
|
|
2556
|
+
|
|
2557
|
+
# Get readable IDs for command text (Commands API expects readable IDs in command)
|
|
2558
|
+
target_readable_id = self._get_readable_id(target_issue_id)
|
|
2559
|
+
|
|
2560
|
+
# Normalize the link_type to lowercase and map to command
|
|
2561
|
+
link_type_lower = link_type.lower()
|
|
2562
|
+
command_phrase = link_command_map.get(link_type_lower, link_type_lower)
|
|
2563
|
+
|
|
2564
|
+
# Build the command using correct YouTrack syntax with readable target ID
|
|
2565
|
+
# Commands expect readable IDs like "DEMO-37" in command text, but internal IDs in issues array
|
|
2566
|
+
command = f"{command_phrase} {target_readable_id}"
|
|
2567
|
+
|
|
2568
|
+
command_data = {
|
|
2569
|
+
"query": command,
|
|
2570
|
+
"issues": [{"id": source_internal_id}],
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
response = self.client.post("commands", data=command_data)
|
|
2574
|
+
|
|
2575
|
+
# Return success response if the command executed
|
|
2576
|
+
if isinstance(response, dict):
|
|
2577
|
+
return {
|
|
2578
|
+
"status": "success",
|
|
2579
|
+
"message": f"Successfully linked {source_issue_id} to {target_issue_id} with relationship '{link_type}'",
|
|
2580
|
+
"command": command,
|
|
2581
|
+
"source_issue": source_issue_id,
|
|
2582
|
+
"target_issue": target_issue_id,
|
|
2583
|
+
"link_type": link_type,
|
|
2584
|
+
"internal_ids": {
|
|
2585
|
+
"source": source_internal_id,
|
|
2586
|
+
"target": target_internal_id,
|
|
2587
|
+
},
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
return response
|
|
2591
|
+
|
|
2592
|
+
def get_issue_links(self, issue_id: str) -> dict:
|
|
2593
|
+
"""
|
|
2594
|
+
Get all links for an issue.
|
|
2595
|
+
|
|
2596
|
+
Args:
|
|
2597
|
+
issue_id: The ID of the issue
|
|
2598
|
+
|
|
2599
|
+
Returns:
|
|
2600
|
+
Dictionary containing inward and outward issue links
|
|
2601
|
+
"""
|
|
2602
|
+
fields = "id,summary,linkType(name,localizedName),direction"
|
|
2603
|
+
response = self.client.get(f"issues/{issue_id}/links?fields={fields}")
|
|
2604
|
+
return response
|
|
2605
|
+
|
|
2606
|
+
def get_available_link_types(self) -> dict:
|
|
2607
|
+
"""
|
|
2608
|
+
Get all available issue link types.
|
|
2609
|
+
|
|
2610
|
+
Returns:
|
|
2611
|
+
List of available link types with their properties
|
|
2612
|
+
"""
|
|
2613
|
+
fields = "name,localizedName,sourceToTarget,targetToSource"
|
|
2614
|
+
response = self.client.get(f"issueLinkTypes?fields={fields}")
|
|
2615
|
+
return response
|
|
2616
|
+
|
|
2617
|
+
def _validate_custom_field_value(
|
|
2618
|
+
self,
|
|
2619
|
+
project_id: str,
|
|
2620
|
+
field_name: str,
|
|
2621
|
+
field_value: Any
|
|
2622
|
+
) -> bool:
|
|
2623
|
+
"""
|
|
2624
|
+
Internal method to validate a custom field value.
|
|
2625
|
+
|
|
2626
|
+
Args:
|
|
2627
|
+
project_id: The project ID
|
|
2628
|
+
field_name: The custom field name
|
|
2629
|
+
field_value: The value to validate
|
|
2630
|
+
|
|
2631
|
+
Returns:
|
|
2632
|
+
True if valid, False otherwise
|
|
2633
|
+
"""
|
|
2634
|
+
try:
|
|
2635
|
+
# Get field schema from project
|
|
2636
|
+
field_schema = self._get_custom_field_schema(project_id, field_name)
|
|
2637
|
+
if not field_schema:
|
|
2638
|
+
# If we can't get schema, assume valid (fallback)
|
|
2639
|
+
return True
|
|
2640
|
+
|
|
2641
|
+
field_type = field_schema.get("type", "")
|
|
2642
|
+
|
|
2643
|
+
# Type-specific validation
|
|
2644
|
+
if field_type in ["StateMachineBundle", "StateBundle"]:
|
|
2645
|
+
# State field - validate against available states
|
|
2646
|
+
allowed_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
2647
|
+
return str(field_value) in [str(v) for v in allowed_values]
|
|
2648
|
+
|
|
2649
|
+
elif field_type in ["EnumBundle", "OwnedBundle"]:
|
|
2650
|
+
# Enum field - validate against enum values
|
|
2651
|
+
allowed_values = self._get_custom_field_allowed_values(project_id, field_name)
|
|
2652
|
+
return str(field_value) in [str(v) for v in allowed_values]
|
|
2653
|
+
|
|
2654
|
+
elif field_type == "UserBundle":
|
|
2655
|
+
# User field - validate user exists
|
|
2656
|
+
return self._validate_user_exists(field_value)
|
|
2657
|
+
|
|
2658
|
+
elif field_type == "DateTimeBundle":
|
|
2659
|
+
# Date field - validate format
|
|
2660
|
+
return self._validate_date_format(field_value)
|
|
2661
|
+
|
|
2662
|
+
elif field_type in ["IntegerBundle", "FloatBundle"]:
|
|
2663
|
+
# Numeric field - validate numeric value
|
|
2664
|
+
return self._validate_numeric_value(field_value, field_type)
|
|
2665
|
+
|
|
2666
|
+
else:
|
|
2667
|
+
# String or unknown type - minimal validation
|
|
2668
|
+
return field_value is not None
|
|
2669
|
+
|
|
2670
|
+
except Exception:
|
|
2671
|
+
# If validation fails due to API errors, assume valid (fallback)
|
|
2672
|
+
return True
|
|
2673
|
+
|
|
2674
|
+
def _get_custom_field_schema(self, project_id: str, field_name: str) -> Optional[Dict[str, Any]]:
|
|
2675
|
+
"""Get custom field schema from project."""
|
|
2676
|
+
try:
|
|
2677
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields")
|
|
2678
|
+
for field in fields:
|
|
2679
|
+
if field.get("field", {}).get("name") == field_name:
|
|
2680
|
+
return field.get("field", {})
|
|
2681
|
+
return None
|
|
2682
|
+
except Exception:
|
|
2683
|
+
return None
|
|
2684
|
+
|
|
2685
|
+
def _get_custom_field_allowed_values(self, project_id: str, field_name: str) -> List[Any]:
|
|
2686
|
+
"""Get allowed values for enum/state fields."""
|
|
2687
|
+
try:
|
|
2688
|
+
field_schema = self._get_custom_field_schema(project_id, field_name)
|
|
2689
|
+
if not field_schema:
|
|
2690
|
+
return []
|
|
2691
|
+
|
|
2692
|
+
field_type = field_schema.get("fieldType", {}).get("valueType")
|
|
2693
|
+
if field_type in ["enum", "state"]:
|
|
2694
|
+
# Get bundle values
|
|
2695
|
+
bundle_id = field_schema.get("fieldType", {}).get("id")
|
|
2696
|
+
if bundle_id:
|
|
2697
|
+
bundle = self.client.get(f"admin/customFieldSettings/bundles/{field_type}/{bundle_id}")
|
|
2698
|
+
return [value.get("name", "") for value in bundle.get("values", [])]
|
|
2699
|
+
|
|
2700
|
+
return []
|
|
2701
|
+
except Exception:
|
|
2702
|
+
return []
|
|
2703
|
+
|
|
2704
|
+
def _validate_user_exists(self, user_value: str) -> bool:
|
|
2705
|
+
"""Validate that a user exists."""
|
|
2706
|
+
try:
|
|
2707
|
+
# Try to get user by login or ID
|
|
2708
|
+
self.client.get(f"users/{user_value}")
|
|
2709
|
+
return True
|
|
2710
|
+
except Exception:
|
|
2711
|
+
return False
|
|
2712
|
+
|
|
2713
|
+
def _validate_date_format(self, date_value: Any) -> bool:
|
|
2714
|
+
"""Validate date format."""
|
|
2715
|
+
if isinstance(date_value, int):
|
|
2716
|
+
# Unix timestamp
|
|
2717
|
+
return date_value > 0
|
|
2718
|
+
elif isinstance(date_value, str):
|
|
2719
|
+
# ISO date string or other formats
|
|
2720
|
+
import datetime
|
|
2721
|
+
try:
|
|
2722
|
+
datetime.datetime.fromisoformat(date_value.replace('Z', '+00:00'))
|
|
2723
|
+
return True
|
|
2724
|
+
except ValueError:
|
|
2725
|
+
return False
|
|
2726
|
+
return False
|
|
2727
|
+
|
|
2728
|
+
def _validate_numeric_value(self, value: Any, field_type: str) -> bool:
|
|
2729
|
+
"""Validate numeric value."""
|
|
2730
|
+
try:
|
|
2731
|
+
if field_type == "IntegerBundle":
|
|
2732
|
+
int(value)
|
|
2733
|
+
return True
|
|
2734
|
+
elif field_type == "FloatBundle":
|
|
2735
|
+
float(value)
|
|
2736
|
+
return True
|
|
2737
|
+
except (ValueError, TypeError):
|
|
2738
|
+
return False
|
|
2739
|
+
return False
|
|
2740
|
+
|
|
2741
|
+
def _format_custom_field_value(self, field_name: str, field_value: Any) -> Dict[str, Any]:
|
|
2742
|
+
"""Format custom field value for YouTrack API."""
|
|
2743
|
+
# YouTrack API expects different formats for different field types
|
|
2744
|
+
# The key issue: we need to use field ID, not name, and proper value format
|
|
2745
|
+
|
|
2746
|
+
if field_value is None:
|
|
2747
|
+
return {
|
|
2748
|
+
"name": field_name,
|
|
2749
|
+
"value": None
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
# For most string-based fields (State, Enum, etc.)
|
|
2753
|
+
if isinstance(field_value, str):
|
|
2754
|
+
return {
|
|
2755
|
+
"name": field_name,
|
|
2756
|
+
"value": {"name": field_value}
|
|
2757
|
+
}
|
|
2758
|
+
# For user fields, expect login format
|
|
2759
|
+
elif isinstance(field_value, dict) and "login" in field_value:
|
|
2760
|
+
return {
|
|
2761
|
+
"name": field_name,
|
|
2762
|
+
"value": {"login": field_value["login"]}
|
|
2763
|
+
}
|
|
2764
|
+
# For user fields as string (login)
|
|
2765
|
+
elif isinstance(field_value, str) and field_name.lower() in ["assignee", "reporter"]:
|
|
2766
|
+
return {
|
|
2767
|
+
"name": field_name,
|
|
2768
|
+
"value": {"login": field_value}
|
|
2769
|
+
}
|
|
2770
|
+
# For numeric fields (Period, Integer, Float)
|
|
2771
|
+
elif isinstance(field_value, (int, float)):
|
|
2772
|
+
return {
|
|
2773
|
+
"name": field_name,
|
|
2774
|
+
"value": field_value
|
|
2775
|
+
}
|
|
2776
|
+
# For period fields (time tracking)
|
|
2777
|
+
elif isinstance(field_value, str) and field_value.startswith("PT"):
|
|
2778
|
+
return {
|
|
2779
|
+
"name": field_name,
|
|
2780
|
+
"value": {"presentation": field_value}
|
|
2781
|
+
}
|
|
2782
|
+
# For complex objects (already formatted)
|
|
2783
|
+
elif isinstance(field_value, dict):
|
|
2784
|
+
return {
|
|
2785
|
+
"name": field_name,
|
|
2786
|
+
"value": field_value
|
|
2787
|
+
}
|
|
2788
|
+
# Default fallback
|
|
2789
|
+
else:
|
|
2790
|
+
return {
|
|
2791
|
+
"name": field_name,
|
|
2792
|
+
"value": {"name": str(field_value)}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
def _get_custom_field_id(self, project_id: str, field_name: str) -> Optional[str]:
|
|
2796
|
+
"""Get the field ID for a custom field by name."""
|
|
2797
|
+
try:
|
|
2798
|
+
# Use detailed fields query to get complete field information
|
|
2799
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
2800
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
2801
|
+
for field in fields:
|
|
2802
|
+
if field.get("field", {}).get("name") == field_name:
|
|
2803
|
+
return field.get("field", {}).get("id")
|
|
2804
|
+
return None
|
|
2805
|
+
except Exception as e:
|
|
2806
|
+
logger.warning(f"Error getting field ID for '{field_name}': {str(e)}")
|
|
2807
|
+
return None
|
|
2808
|
+
|
|
2809
|
+
def _get_field_type_info(self, project_id: str, field_id: str) -> Dict[str, Any]:
|
|
2810
|
+
"""Get field type information for proper $type formatting."""
|
|
2811
|
+
try:
|
|
2812
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
2813
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
2814
|
+
|
|
2815
|
+
for field in fields:
|
|
2816
|
+
if field.get("field", {}).get("id") == field_id:
|
|
2817
|
+
field_type = field.get("field", {}).get("fieldType", {})
|
|
2818
|
+
return {
|
|
2819
|
+
"bundle_type": field_type.get("$type", ""),
|
|
2820
|
+
"value_type": field_type.get("valueType", ""),
|
|
2821
|
+
"bundle_id": field_type.get("id")
|
|
2822
|
+
}
|
|
2823
|
+
return {}
|
|
2824
|
+
except Exception as e:
|
|
2825
|
+
logger.warning(f"Error getting field type info for field ID '{field_id}': {str(e)}")
|
|
2826
|
+
return {}
|
|
2827
|
+
|
|
2828
|
+
def _format_custom_field_value_with_id(self, field_id: str, field_value: Any, project_id: str = None) -> Dict[str, Any]:
|
|
2829
|
+
"""Format custom field value with field ID for YouTrack API."""
|
|
2830
|
+
# YouTrack API format: Complete IssueCustomField object with proper $type
|
|
2831
|
+
|
|
2832
|
+
# Get field type information to determine the correct IssueCustomField $type
|
|
2833
|
+
field_type_info = self._get_field_type_info(project_id, field_id) if project_id else {}
|
|
2834
|
+
bundle_type = field_type_info.get("bundle_type", "")
|
|
2835
|
+
value_type = field_type_info.get("value_type", "")
|
|
2836
|
+
|
|
2837
|
+
# Determine the IssueCustomField $type based on the bundle type and value type
|
|
2838
|
+
issue_field_type = self._get_issue_custom_field_type(bundle_type, value_type, field_id)
|
|
2839
|
+
|
|
2840
|
+
# Format the value based on type
|
|
2841
|
+
formatted_value = self._format_field_value(field_value, bundle_type, value_type, field_id)
|
|
2842
|
+
|
|
2843
|
+
return {
|
|
2844
|
+
"id": field_id,
|
|
2845
|
+
"value": formatted_value,
|
|
2846
|
+
"$type": issue_field_type
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
def _get_issue_custom_field_type(self, bundle_type: str, value_type: str, field_id: str) -> str:
|
|
2850
|
+
"""Determine the correct IssueCustomField $type based on field information."""
|
|
2851
|
+
|
|
2852
|
+
# Check for user fields first (special case)
|
|
2853
|
+
if any(keyword in field_id.lower() for keyword in ["assignee", "reporter", "user"]) or "UserBundle" in bundle_type:
|
|
2854
|
+
return "SingleUserIssueCustomField"
|
|
2855
|
+
|
|
2856
|
+
# Map based on value type and bundle type
|
|
2857
|
+
if value_type == "enum":
|
|
2858
|
+
return "SingleEnumIssueCustomField"
|
|
2859
|
+
elif value_type == "state":
|
|
2860
|
+
return "StateIssueCustomField"
|
|
2861
|
+
elif value_type == "user":
|
|
2862
|
+
return "SingleUserIssueCustomField"
|
|
2863
|
+
elif value_type == "period":
|
|
2864
|
+
return "PeriodIssueCustomField"
|
|
2865
|
+
elif value_type == "version":
|
|
2866
|
+
return "MultiVersionIssueCustomField"
|
|
2867
|
+
elif value_type in ["integer", "float", "string", "date"]:
|
|
2868
|
+
return "SimpleIssueCustomField"
|
|
2869
|
+
elif value_type == "text":
|
|
2870
|
+
return "TextIssueCustomField"
|
|
2871
|
+
elif "StateBundle" in bundle_type or "StateMachine" in bundle_type:
|
|
2872
|
+
return "StateIssueCustomField"
|
|
2873
|
+
elif "EnumBundle" in bundle_type:
|
|
2874
|
+
return "SingleEnumIssueCustomField"
|
|
2875
|
+
elif "UserBundle" in bundle_type:
|
|
2876
|
+
return "SingleUserIssueCustomField"
|
|
2877
|
+
elif "VersionBundle" in bundle_type:
|
|
2878
|
+
return "MultiVersionIssueCustomField"
|
|
2879
|
+
elif "PeriodBundle" in bundle_type:
|
|
2880
|
+
return "PeriodIssueCustomField"
|
|
2881
|
+
else:
|
|
2882
|
+
# Default fallback based on common field names
|
|
2883
|
+
if "priority" in field_id.lower() or "type" in field_id.lower():
|
|
2884
|
+
return "SingleEnumIssueCustomField"
|
|
2885
|
+
elif "state" in field_id.lower():
|
|
2886
|
+
return "StateIssueCustomField"
|
|
2887
|
+
else:
|
|
2888
|
+
return "SingleEnumIssueCustomField" # Most common type
|
|
2889
|
+
|
|
2890
|
+
def _format_field_value(self, field_value: Any, bundle_type: str, value_type: str, field_id: str) -> Any:
|
|
2891
|
+
"""Format the field value based on type information."""
|
|
2892
|
+
|
|
2893
|
+
if field_value is None:
|
|
2894
|
+
return None
|
|
2895
|
+
|
|
2896
|
+
# For user fields
|
|
2897
|
+
if any(keyword in field_id.lower() for keyword in ["assignee", "reporter", "user"]) or "UserBundle" in bundle_type or value_type == "user":
|
|
2898
|
+
if isinstance(field_value, str):
|
|
2899
|
+
return {
|
|
2900
|
+
"login": field_value,
|
|
2901
|
+
"$type": "User"
|
|
2902
|
+
}
|
|
2903
|
+
elif isinstance(field_value, dict) and "login" in field_value:
|
|
2904
|
+
return {
|
|
2905
|
+
"login": field_value["login"],
|
|
2906
|
+
"$type": "User"
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
# For period fields
|
|
2910
|
+
elif value_type == "period" or (isinstance(field_value, str) and field_value.startswith("PT")):
|
|
2911
|
+
return {
|
|
2912
|
+
"presentation": str(field_value),
|
|
2913
|
+
"$type": "PeriodValue"
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
# For state fields
|
|
2917
|
+
elif value_type == "state" or "StateBundle" in bundle_type or "StateMachine" in bundle_type or "state" in field_id.lower():
|
|
2918
|
+
return {
|
|
2919
|
+
"name": str(field_value),
|
|
2920
|
+
"$type": "StateBundleElement"
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
# For version/sprint fields
|
|
2924
|
+
elif value_type == "version" or "VersionBundle" in bundle_type:
|
|
2925
|
+
if isinstance(field_value, list):
|
|
2926
|
+
return [{"name": self._normalize_field_value(v), "$type": "VersionBundleElement"} for v in field_value]
|
|
2927
|
+
else:
|
|
2928
|
+
return [{"name": str(field_value), "$type": "VersionBundleElement"}]
|
|
2929
|
+
|
|
2930
|
+
# For enum fields
|
|
2931
|
+
elif value_type == "enum" or "EnumBundle" in bundle_type:
|
|
2932
|
+
return {
|
|
2933
|
+
"name": str(field_value),
|
|
2934
|
+
"$type": "EnumBundleElement"
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
# For numeric fields
|
|
2938
|
+
elif value_type in ["integer", "float"] and isinstance(field_value, (int, float)):
|
|
2939
|
+
return field_value
|
|
2940
|
+
|
|
2941
|
+
# For text/string fields
|
|
2942
|
+
elif value_type in ["string", "text"]:
|
|
2943
|
+
return str(field_value)
|
|
2944
|
+
|
|
2945
|
+
# For date fields
|
|
2946
|
+
elif value_type == "date":
|
|
2947
|
+
return field_value # Assume it's already properly formatted
|
|
2948
|
+
|
|
2949
|
+
# Default: treat as enum
|
|
2950
|
+
else:
|
|
2951
|
+
return {
|
|
2952
|
+
"name": str(field_value),
|
|
2953
|
+
"$type": "EnumBundleElement"
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
def _extract_custom_field_value(self, field_value_data: Any) -> Any:
|
|
2957
|
+
"""Extract readable value from YouTrack custom field value data."""
|
|
2958
|
+
if not field_value_data:
|
|
2959
|
+
return None
|
|
2960
|
+
|
|
2961
|
+
if isinstance(field_value_data, dict):
|
|
2962
|
+
# Try different value formats
|
|
2963
|
+
if "name" in field_value_data:
|
|
2964
|
+
return field_value_data["name"]
|
|
2965
|
+
elif "login" in field_value_data:
|
|
2966
|
+
return field_value_data["login"]
|
|
2967
|
+
elif "text" in field_value_data:
|
|
2968
|
+
return field_value_data["text"]
|
|
2969
|
+
elif "id" in field_value_data:
|
|
2970
|
+
return field_value_data["id"]
|
|
2971
|
+
|
|
2972
|
+
return field_value_data
|
|
2973
|
+
|
|
2974
|
+
def _determine_field_type(self, field_name: str, value_type: str, bundle_type: str) -> str:
|
|
2975
|
+
"""
|
|
2976
|
+
Determine the correct $type field for YouTrack custom field updates.
|
|
2977
|
+
|
|
2978
|
+
Args:
|
|
2979
|
+
field_name: Name of the field
|
|
2980
|
+
value_type: Type from schema (enum, state, user, period, etc.)
|
|
2981
|
+
bundle_type: Bundle type from schema
|
|
2982
|
+
|
|
2983
|
+
Returns:
|
|
2984
|
+
The appropriate $type string for the API
|
|
2985
|
+
"""
|
|
2986
|
+
# Map field types to YouTrack $type values
|
|
2987
|
+
type_mapping = {
|
|
2988
|
+
"enum": "SingleEnumIssueCustomField",
|
|
2989
|
+
"state": "StateIssueCustomField",
|
|
2990
|
+
"user": "SingleUserIssueCustomField",
|
|
2991
|
+
"period": "PeriodIssueCustomField",
|
|
2992
|
+
"ownedField": "SingleOwnedIssueCustomField", # Subsystem
|
|
2993
|
+
"version": "SingleVersionIssueCustomField", # Fix/Affected versions
|
|
2994
|
+
"build": "SingleBuildIssueCustomField", # Fixed in build
|
|
2995
|
+
"string": "SingleStringIssueCustomField",
|
|
2996
|
+
"text": "TextIssueCustomField",
|
|
2997
|
+
"integer": "SingleIntegerIssueCustomField",
|
|
2998
|
+
"float": "SingleFloatIssueCustomField",
|
|
2999
|
+
"date": "SingleDateIssueCustomField",
|
|
3000
|
+
"datetime": "SingleDateTimeIssueCustomField"
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
# Get the appropriate type, defaulting to enum if not found
|
|
3004
|
+
field_type = type_mapping.get(value_type.lower(), "SingleEnumIssueCustomField")
|
|
3005
|
+
|
|
3006
|
+
logger.debug(f"Field '{field_name}' (type: {value_type}, bundle: {bundle_type}) mapped to $type: {field_type}")
|
|
3007
|
+
|
|
3008
|
+
return field_type
|