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.
Files changed (44) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +332 -0
  3. package/README.npm.md +436 -0
  4. package/dist/bin/youtrack-mcp.js +158 -0
  5. package/dist/index.js +129 -0
  6. package/dist/python/main.py +74 -0
  7. package/dist/python/requirements.txt +13 -0
  8. package/dist/python/youtrack_mcp/__init__.py +5 -0
  9. package/dist/python/youtrack_mcp/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/dist/python/youtrack_mcp/__pycache__/version.cpython-311.pyc +0 -0
  11. package/dist/python/youtrack_mcp/api/__init__.py +3 -0
  12. package/dist/python/youtrack_mcp/api/articles.py +317 -0
  13. package/dist/python/youtrack_mcp/api/client.py +466 -0
  14. package/dist/python/youtrack_mcp/api/issues.py +3008 -0
  15. package/dist/python/youtrack_mcp/api/mcp_wrappers.py +304 -0
  16. package/dist/python/youtrack_mcp/api/projects.py +1009 -0
  17. package/dist/python/youtrack_mcp/api/search.py +244 -0
  18. package/dist/python/youtrack_mcp/api/spaces.py +46 -0
  19. package/dist/python/youtrack_mcp/api/users.py +149 -0
  20. package/dist/python/youtrack_mcp/config.py +273 -0
  21. package/dist/python/youtrack_mcp/mcp_server.py +158 -0
  22. package/dist/python/youtrack_mcp/mcp_wrappers.py +324 -0
  23. package/dist/python/youtrack_mcp/tools/__init__.py +8 -0
  24. package/dist/python/youtrack_mcp/tools/articles.py +487 -0
  25. package/dist/python/youtrack_mcp/tools/create_project_tool.py +51 -0
  26. package/dist/python/youtrack_mcp/tools/issues/__init__.py +208 -0
  27. package/dist/python/youtrack_mcp/tools/issues/attachments.py +161 -0
  28. package/dist/python/youtrack_mcp/tools/issues/basic_operations.py +322 -0
  29. package/dist/python/youtrack_mcp/tools/issues/custom_fields.py +365 -0
  30. package/dist/python/youtrack_mcp/tools/issues/dedicated_updates.py +601 -0
  31. package/dist/python/youtrack_mcp/tools/issues/diagnostics.py +363 -0
  32. package/dist/python/youtrack_mcp/tools/issues/linking.py +283 -0
  33. package/dist/python/youtrack_mcp/tools/issues/utilities.py +291 -0
  34. package/dist/python/youtrack_mcp/tools/loader.py +383 -0
  35. package/dist/python/youtrack_mcp/tools/projects.py +826 -0
  36. package/dist/python/youtrack_mcp/tools/projects.py-e +411 -0
  37. package/dist/python/youtrack_mcp/tools/resources.py +744 -0
  38. package/dist/python/youtrack_mcp/tools/search.py +297 -0
  39. package/dist/python/youtrack_mcp/tools/spaces.py +69 -0
  40. package/dist/python/youtrack_mcp/tools/users.py +185 -0
  41. package/dist/python/youtrack_mcp/utils.py +87 -0
  42. package/dist/python/youtrack_mcp/version.py +5 -0
  43. package/package.json +61 -0
  44. 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