mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__py3-none-any.whl

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.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -6,20 +6,28 @@ import logging
6
6
  import re
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
- from typing import Any, Optional, Union
9
+ from typing import Any, Union
10
10
 
11
11
  import httpx
12
12
  from httpx import AsyncClient, HTTPStatusError, TimeoutException
13
13
 
14
14
  from ..core.adapter import BaseAdapter
15
15
  from ..core.env_loader import load_adapter_config, validate_adapter_config
16
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
16
+ from ..core.models import (
17
+ Attachment,
18
+ Comment,
19
+ Epic,
20
+ Priority,
21
+ SearchQuery,
22
+ Task,
23
+ TicketState,
24
+ )
17
25
  from ..core.registry import AdapterRegistry
18
26
 
19
27
  logger = logging.getLogger(__name__)
20
28
 
21
29
 
22
- def parse_jira_datetime(date_str: str) -> Optional[datetime]:
30
+ def parse_jira_datetime(date_str: str) -> datetime | None:
23
31
  """Parse JIRA datetime strings which can be in various formats.
24
32
 
25
33
  JIRA can return dates in formats like:
@@ -47,7 +55,7 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
47
55
  return None
48
56
 
49
57
 
50
- def extract_text_from_adf(adf_content: Union[str, dict[str, Any]]) -> str:
58
+ def extract_text_from_adf(adf_content: str | dict[str, Any]) -> str:
51
59
  """Extract plain text from Atlassian Document Format (ADF).
52
60
 
53
61
  Args:
@@ -221,8 +229,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
221
229
  self,
222
230
  method: str,
223
231
  endpoint: str,
224
- data: Optional[dict[str, Any]] = None,
225
- params: Optional[dict[str, Any]] = None,
232
+ data: dict[str, Any] | None = None,
233
+ params: dict[str, Any] | None = None,
226
234
  retry_count: int = 0,
227
235
  ) -> dict[str, Any]:
228
236
  """Make HTTP request to JIRA API with retry logic.
@@ -287,7 +295,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
287
295
  return self._priority_cache
288
296
 
289
297
  async def _get_issue_types(
290
- self, project_key: Optional[str] = None
298
+ self, project_key: str | None = None
291
299
  ) -> list[dict[str, Any]]:
292
300
  """Get available issue types for a project."""
293
301
  key = project_key or self.project_key
@@ -380,9 +388,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
380
388
  }
381
389
  return mapping.get(priority, JiraPriority.MEDIUM)
382
390
 
383
- def _map_priority_from_jira(
384
- self, jira_priority: Optional[dict[str, Any]]
385
- ) -> Priority:
391
+ def _map_priority_from_jira(self, jira_priority: dict[str, Any] | None) -> Priority:
386
392
  """Map JIRA priority to universal priority."""
387
393
  if not jira_priority:
388
394
  return Priority.MEDIUM
@@ -432,7 +438,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
432
438
  else:
433
439
  return TicketState.OPEN
434
440
 
435
- def _issue_to_ticket(self, issue: dict[str, Any]) -> Union[Epic, Task]:
441
+ def _issue_to_ticket(self, issue: dict[str, Any]) -> Epic | Task:
436
442
  """Convert JIRA issue to universal ticket model."""
437
443
  fields = issue.get("fields", {})
438
444
 
@@ -507,7 +513,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
507
513
  )
508
514
 
509
515
  def _ticket_to_issue_fields(
510
- self, ticket: Union[Epic, Task], issue_type: Optional[str] = None
516
+ self, ticket: Epic | Task, issue_type: str | None = None
511
517
  ) -> dict[str, Any]:
512
518
  """Convert universal ticket to JIRA issue fields."""
513
519
  # Convert description to ADF format for JIRA Cloud
@@ -556,7 +562,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
556
562
 
557
563
  return fields
558
564
 
559
- async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
565
+ async def create(self, ticket: Epic | Task) -> Epic | Task:
560
566
  """Create a new JIRA issue."""
561
567
  # Validate credentials before attempting operation
562
568
  is_valid, error_message = self.validate_credentials()
@@ -576,7 +582,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
576
582
  created_issue = await self._make_request("GET", f"issue/{ticket.id}")
577
583
  return self._issue_to_ticket(created_issue)
578
584
 
579
- async def read(self, ticket_id: str) -> Optional[Union[Epic, Task]]:
585
+ async def read(self, ticket_id: str) -> Epic | Task | None:
580
586
  """Read a JIRA issue by key."""
581
587
  # Validate credentials before attempting operation
582
588
  is_valid, error_message = self.validate_credentials()
@@ -595,7 +601,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
595
601
 
596
602
  async def update(
597
603
  self, ticket_id: str, updates: dict[str, Any]
598
- ) -> Optional[Union[Epic, Task]]:
604
+ ) -> Epic | Task | None:
599
605
  """Update a JIRA issue."""
600
606
  # Validate credentials before attempting operation
601
607
  is_valid, error_message = self.validate_credentials()
@@ -652,8 +658,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
652
658
  raise
653
659
 
654
660
  async def list(
655
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
656
- ) -> list[Union[Epic, Task]]:
661
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
662
+ ) -> list[Epic | Task]:
657
663
  """List JIRA issues with pagination."""
658
664
  # Build JQL query
659
665
  jql_parts = []
@@ -692,7 +698,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
692
698
  issues = data.get("issues", [])
693
699
  return [self._issue_to_ticket(issue) for issue in issues]
694
700
 
695
- async def search(self, query: SearchQuery) -> builtins.list[Union[Epic, Task]]:
701
+ async def search(self, query: SearchQuery) -> builtins.list[Epic | Task]:
696
702
  """Search JIRA issues using JQL."""
697
703
  # Build JQL query
698
704
  jql_parts = []
@@ -744,7 +750,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
744
750
 
745
751
  async def transition_state(
746
752
  self, ticket_id: str, target_state: TicketState
747
- ) -> Optional[Union[Epic, Task]]:
753
+ ) -> Epic | Task | None:
748
754
  """Transition JIRA issue to a new state."""
749
755
  # Get available transitions
750
756
  transitions = await self._get_transitions(ticket_id)
@@ -858,9 +864,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
858
864
 
859
865
  return comments
860
866
 
861
- async def get_project_info(
862
- self, project_key: Optional[str] = None
863
- ) -> dict[str, Any]:
867
+ async def get_project_info(self, project_key: str | None = None) -> dict[str, Any]:
864
868
  """Get JIRA project information including workflows and fields."""
865
869
  key = project_key or self.project_key
866
870
  if not key:
@@ -882,7 +886,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
882
886
 
883
887
  async def execute_jql(
884
888
  self, jql: str, limit: int = 50
885
- ) -> builtins.list[Union[Epic, Task]]:
889
+ ) -> builtins.list[Epic | Task]:
886
890
  """Execute a raw JQL query.
887
891
 
888
892
  Args:
@@ -908,7 +912,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
908
912
  return [self._issue_to_ticket(issue) for issue in issues]
909
913
 
910
914
  async def get_sprints(
911
- self, board_id: Optional[int] = None
915
+ self, board_id: int | None = None
912
916
  ) -> builtins.list[dict[str, Any]]:
913
917
  """Get active sprints for a board (requires JIRA Software).
914
918
 
@@ -992,13 +996,255 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
992
996
  except Exception:
993
997
  return []
994
998
 
995
- async def get_current_user(self) -> Optional[dict[str, Any]]:
999
+ async def get_current_user(self) -> dict[str, Any] | None:
996
1000
  """Get current authenticated user information."""
997
1001
  try:
998
1002
  return await self._make_request("GET", "myself")
999
1003
  except Exception:
1000
1004
  return None
1001
1005
 
1006
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
1007
+ """List all labels used in the project.
1008
+
1009
+ JIRA doesn't have a direct "list all labels" endpoint, so we query
1010
+ recent issues and extract unique labels from them.
1011
+
1012
+ Returns:
1013
+ List of label dictionaries with 'id' and 'name' fields
1014
+
1015
+ """
1016
+ try:
1017
+ # Query recent issues to get labels in use
1018
+ jql = f"project = {self.project_key} ORDER BY updated DESC"
1019
+ data = await self._make_request(
1020
+ "GET",
1021
+ "search/jql",
1022
+ params={
1023
+ "jql": jql,
1024
+ "maxResults": 100, # Sample from recent 100 issues
1025
+ "fields": "labels",
1026
+ },
1027
+ )
1028
+
1029
+ # Collect unique labels
1030
+ unique_labels = set()
1031
+ for issue in data.get("issues", []):
1032
+ labels = issue.get("fields", {}).get("labels", [])
1033
+ for label in labels:
1034
+ if isinstance(label, dict):
1035
+ unique_labels.add(label.get("name", ""))
1036
+ else:
1037
+ unique_labels.add(str(label))
1038
+
1039
+ # Transform to standardized format
1040
+ return [
1041
+ {"id": label, "name": label} for label in sorted(unique_labels) if label
1042
+ ]
1043
+
1044
+ except Exception:
1045
+ # Fallback: return empty list if query fails
1046
+ return []
1047
+
1048
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1049
+ """Update a JIRA Epic with epic-specific field handling.
1050
+
1051
+ Args:
1052
+ epic_id: Epic identifier (key like PROJ-123 or ID)
1053
+ updates: Dictionary with fields to update:
1054
+ - title: Epic title (maps to summary)
1055
+ - description: Epic description (auto-converted to ADF)
1056
+ - state: TicketState value (transitions via workflow)
1057
+ - tags: List of labels
1058
+ - priority: Priority level
1059
+
1060
+ Returns:
1061
+ Updated Epic object or None if not found
1062
+
1063
+ Raises:
1064
+ ValueError: If no fields provided for update
1065
+ HTTPStatusError: If update fails
1066
+
1067
+ """
1068
+ fields = {}
1069
+
1070
+ # Map title to summary
1071
+ if "title" in updates:
1072
+ fields["summary"] = updates["title"]
1073
+
1074
+ # Convert description to ADF format
1075
+ if "description" in updates:
1076
+ fields["description"] = self._convert_to_adf(updates["description"])
1077
+
1078
+ # Map tags to labels
1079
+ if "tags" in updates:
1080
+ fields["labels"] = updates["tags"]
1081
+
1082
+ # Map priority (some JIRA configs allow priority on Epics)
1083
+ if "priority" in updates:
1084
+ priority_value = updates["priority"]
1085
+ if isinstance(priority_value, Priority):
1086
+ fields["priority"] = {
1087
+ "name": self._map_priority_to_jira(priority_value)
1088
+ }
1089
+ else:
1090
+ # String priority passed directly
1091
+ fields["priority"] = {"name": priority_value}
1092
+
1093
+ if not fields and "state" not in updates:
1094
+ raise ValueError("At least one field must be updated")
1095
+
1096
+ # Apply field updates if any
1097
+ if fields:
1098
+ await self._make_request("PUT", f"issue/{epic_id}", data={"fields": fields})
1099
+
1100
+ # Handle state transitions separately (JIRA uses workflow transitions)
1101
+ if "state" in updates:
1102
+ await self.transition_state(epic_id, updates["state"])
1103
+
1104
+ # Fetch and return updated epic
1105
+ return await self.read(epic_id)
1106
+
1107
+ async def add_attachment(
1108
+ self, ticket_id: str, file_path: str, description: str | None = None
1109
+ ) -> Attachment:
1110
+ """Attach file to JIRA issue (including Epic).
1111
+
1112
+ Args:
1113
+ ticket_id: Issue key (e.g., PROJ-123) or ID
1114
+ file_path: Path to file to attach
1115
+ description: Optional description (stored in metadata, not used by JIRA directly)
1116
+
1117
+ Returns:
1118
+ Attachment object with metadata
1119
+
1120
+ Raises:
1121
+ FileNotFoundError: If file doesn't exist
1122
+ ValueError: If credentials invalid
1123
+ HTTPStatusError: If upload fails
1124
+
1125
+ """
1126
+ from pathlib import Path
1127
+
1128
+ # Validate credentials before attempting operation
1129
+ is_valid, error_message = self.validate_credentials()
1130
+ if not is_valid:
1131
+ raise ValueError(error_message)
1132
+
1133
+ file_path_obj = Path(file_path)
1134
+ if not file_path_obj.exists():
1135
+ raise FileNotFoundError(f"File not found: {file_path}")
1136
+
1137
+ # JIRA requires special header for attachment upload
1138
+ headers = {
1139
+ "X-Atlassian-Token": "no-check",
1140
+ # Don't set Content-Type - let httpx handle multipart
1141
+ }
1142
+
1143
+ # Prepare multipart file upload
1144
+ with open(file_path_obj, "rb") as f:
1145
+ files = {"file": (file_path_obj.name, f, "application/octet-stream")}
1146
+
1147
+ url = f"{self.api_base}/issue/{ticket_id}/attachments"
1148
+
1149
+ # Use existing client infrastructure
1150
+ async with await self._get_client() as client:
1151
+ response = await client.post(
1152
+ url, files=files, headers={**self.headers, **headers}
1153
+ )
1154
+ response.raise_for_status()
1155
+
1156
+ # JIRA returns array with single attachment
1157
+ attachment_data = response.json()[0]
1158
+
1159
+ return Attachment(
1160
+ id=attachment_data["id"],
1161
+ ticket_id=ticket_id,
1162
+ filename=attachment_data["filename"],
1163
+ url=attachment_data["content"],
1164
+ content_type=attachment_data["mimeType"],
1165
+ size_bytes=attachment_data["size"],
1166
+ created_at=parse_jira_datetime(attachment_data["created"]),
1167
+ created_by=attachment_data["author"]["displayName"],
1168
+ description=description,
1169
+ metadata={"jira": attachment_data},
1170
+ )
1171
+
1172
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
1173
+ """Get all attachments for a JIRA issue.
1174
+
1175
+ Args:
1176
+ ticket_id: Issue key or ID
1177
+
1178
+ Returns:
1179
+ List of Attachment objects
1180
+
1181
+ Raises:
1182
+ ValueError: If credentials invalid
1183
+ HTTPStatusError: If request fails
1184
+
1185
+ """
1186
+ # Validate credentials before attempting operation
1187
+ is_valid, error_message = self.validate_credentials()
1188
+ if not is_valid:
1189
+ raise ValueError(error_message)
1190
+
1191
+ # Fetch issue with attachment field
1192
+ issue = await self._make_request(
1193
+ "GET", f"issue/{ticket_id}", params={"fields": "attachment"}
1194
+ )
1195
+
1196
+ attachments = []
1197
+ for att_data in issue.get("fields", {}).get("attachment", []):
1198
+ attachments.append(
1199
+ Attachment(
1200
+ id=att_data["id"],
1201
+ ticket_id=ticket_id,
1202
+ filename=att_data["filename"],
1203
+ url=att_data["content"],
1204
+ content_type=att_data["mimeType"],
1205
+ size_bytes=att_data["size"],
1206
+ created_at=parse_jira_datetime(att_data["created"]),
1207
+ created_by=att_data["author"]["displayName"],
1208
+ metadata={"jira": att_data},
1209
+ )
1210
+ )
1211
+
1212
+ return attachments
1213
+
1214
+ async def delete_attachment(self, ticket_id: str, attachment_id: str) -> bool:
1215
+ """Delete an attachment from a JIRA issue.
1216
+
1217
+ Args:
1218
+ ticket_id: Issue key or ID (for validation/context)
1219
+ attachment_id: Attachment ID to delete
1220
+
1221
+ Returns:
1222
+ True if deleted successfully, False otherwise
1223
+
1224
+ Raises:
1225
+ ValueError: If credentials invalid
1226
+
1227
+ """
1228
+ # Validate credentials before attempting operation
1229
+ is_valid, error_message = self.validate_credentials()
1230
+ if not is_valid:
1231
+ raise ValueError(error_message)
1232
+
1233
+ try:
1234
+ await self._make_request("DELETE", f"attachment/{attachment_id}")
1235
+ return True
1236
+ except HTTPStatusError as e:
1237
+ if e.response.status_code == 404:
1238
+ logger.warning(f"Attachment {attachment_id} not found")
1239
+ return False
1240
+ logger.error(
1241
+ f"Failed to delete attachment {attachment_id}: {e.response.status_code} - {e.response.text}"
1242
+ )
1243
+ return False
1244
+ except Exception as e:
1245
+ logger.error(f"Unexpected error deleting attachment {attachment_id}: {e}")
1246
+ return False
1247
+
1002
1248
  async def close(self) -> None:
1003
1249
  """Close the adapter and cleanup resources."""
1004
1250
  # Clear caches