@techwavedev/agi-agent-kit 1.1.7 → 1.2.1

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 @techwavedev/agi-agent-kit might be problematic. Click here for more details.

Files changed (111) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +190 -12
  3. package/bin/init.js +30 -2
  4. package/package.json +6 -3
  5. package/templates/base/AGENTS.md +54 -23
  6. package/templates/base/README.md +325 -0
  7. package/templates/base/directives/memory_integration.md +95 -0
  8. package/templates/base/execution/memory_manager.py +309 -0
  9. package/templates/base/execution/session_boot.py +218 -0
  10. package/templates/base/execution/session_init.py +320 -0
  11. package/templates/base/skill-creator/SKILL_skillcreator.md +23 -36
  12. package/templates/base/skill-creator/scripts/init_skill.py +18 -135
  13. package/templates/skills/ec/README.md +31 -0
  14. package/templates/skills/ec/aws/SKILL.md +1020 -0
  15. package/templates/skills/ec/aws/defaults.yaml +13 -0
  16. package/templates/skills/ec/aws/references/common_patterns.md +80 -0
  17. package/templates/skills/ec/aws/references/mcp_servers.md +98 -0
  18. package/templates/skills/ec/aws-terraform/SKILL.md +349 -0
  19. package/templates/skills/ec/aws-terraform/references/best_practices.md +394 -0
  20. package/templates/skills/ec/aws-terraform/references/checkov_reference.md +337 -0
  21. package/templates/skills/ec/aws-terraform/scripts/configure_mcp.py +150 -0
  22. package/templates/skills/ec/confluent-kafka/SKILL.md +655 -0
  23. package/templates/skills/ec/confluent-kafka/references/ansible_playbooks.md +792 -0
  24. package/templates/skills/ec/confluent-kafka/references/ec_deployment.md +579 -0
  25. package/templates/skills/ec/confluent-kafka/references/kraft_migration.md +490 -0
  26. package/templates/skills/ec/confluent-kafka/references/troubleshooting.md +778 -0
  27. package/templates/skills/ec/confluent-kafka/references/upgrade_7x_to_8x.md +488 -0
  28. package/templates/skills/ec/confluent-kafka/scripts/kafka_health_check.py +435 -0
  29. package/templates/skills/ec/confluent-kafka/scripts/upgrade_preflight.py +568 -0
  30. package/templates/skills/ec/confluent-kafka/scripts/validate_config.py +455 -0
  31. package/templates/skills/ec/consul/SKILL.md +427 -0
  32. package/templates/skills/ec/consul/references/acl_setup.md +168 -0
  33. package/templates/skills/ec/consul/references/ha_config.md +196 -0
  34. package/templates/skills/ec/consul/references/troubleshooting.md +267 -0
  35. package/templates/skills/ec/consul/references/upgrades.md +213 -0
  36. package/templates/skills/ec/consul/scripts/consul_health_report.py +530 -0
  37. package/templates/skills/ec/consul/scripts/consul_status.py +264 -0
  38. package/templates/skills/ec/consul/scripts/generate_values.py +170 -0
  39. package/templates/skills/ec/documentation/SKILL.md +351 -0
  40. package/templates/skills/ec/documentation/references/best_practices.md +201 -0
  41. package/templates/skills/ec/documentation/scripts/analyze_code.py +307 -0
  42. package/templates/skills/ec/documentation/scripts/detect_changes.py +460 -0
  43. package/templates/skills/ec/documentation/scripts/generate_changelog.py +312 -0
  44. package/templates/skills/ec/documentation/scripts/sync_docs.py +272 -0
  45. package/templates/skills/ec/documentation/scripts/update_skill_docs.py +366 -0
  46. package/templates/skills/ec/gitlab/SKILL.md +529 -0
  47. package/templates/skills/ec/gitlab/references/agent_installation.md +416 -0
  48. package/templates/skills/ec/gitlab/references/api_reference.md +508 -0
  49. package/templates/skills/ec/gitlab/references/gitops_flux.md +465 -0
  50. package/templates/skills/ec/gitlab/references/troubleshooting.md +518 -0
  51. package/templates/skills/ec/gitlab/scripts/generate_agent_values.py +329 -0
  52. package/templates/skills/ec/gitlab/scripts/gitlab_agent_status.py +414 -0
  53. package/templates/skills/ec/jira/SKILL.md +484 -0
  54. package/templates/skills/ec/jira/references/jql_reference.md +148 -0
  55. package/templates/skills/ec/jira/scripts/add_comment.py +91 -0
  56. package/templates/skills/ec/jira/scripts/bulk_log_work.py +124 -0
  57. package/templates/skills/ec/jira/scripts/create_ticket.py +162 -0
  58. package/templates/skills/ec/jira/scripts/get_ticket.py +191 -0
  59. package/templates/skills/ec/jira/scripts/jira_client.py +383 -0
  60. package/templates/skills/ec/jira/scripts/log_work.py +154 -0
  61. package/templates/skills/ec/jira/scripts/search_tickets.py +104 -0
  62. package/templates/skills/ec/jira/scripts/update_comment.py +67 -0
  63. package/templates/skills/ec/jira/scripts/update_ticket.py +161 -0
  64. package/templates/skills/ec/karpenter/SKILL.md +301 -0
  65. package/templates/skills/ec/karpenter/references/ec2nodeclasses.md +421 -0
  66. package/templates/skills/ec/karpenter/references/migration.md +396 -0
  67. package/templates/skills/ec/karpenter/references/nodepools.md +400 -0
  68. package/templates/skills/ec/karpenter/references/troubleshooting.md +359 -0
  69. package/templates/skills/ec/karpenter/scripts/generate_ec2nodeclass.py +187 -0
  70. package/templates/skills/ec/karpenter/scripts/generate_nodepool.py +245 -0
  71. package/templates/skills/ec/karpenter/scripts/karpenter_status.py +359 -0
  72. package/templates/skills/ec/opensearch/SKILL.md +720 -0
  73. package/templates/skills/ec/opensearch/references/ml_neural_search.md +576 -0
  74. package/templates/skills/ec/opensearch/references/operator.md +532 -0
  75. package/templates/skills/ec/opensearch/references/query_dsl.md +532 -0
  76. package/templates/skills/ec/opensearch/scripts/configure_mcp.py +148 -0
  77. package/templates/skills/ec/victoriametrics/SKILL.md +598 -0
  78. package/templates/skills/ec/victoriametrics/references/kubernetes.md +531 -0
  79. package/templates/skills/ec/victoriametrics/references/prometheus_migration.md +333 -0
  80. package/templates/skills/ec/victoriametrics/references/troubleshooting.md +442 -0
  81. package/templates/skills/knowledge/SKILLS_CATALOG.md +274 -4
  82. package/templates/skills/knowledge/intelligent-routing/SKILL.md +237 -164
  83. package/templates/skills/knowledge/parallel-agents/SKILL.md +345 -73
  84. package/templates/skills/knowledge/plugin-discovery/SKILL.md +582 -0
  85. package/templates/skills/knowledge/plugin-discovery/scripts/platform_setup.py +1083 -0
  86. package/templates/skills/knowledge/design-md/README.md +0 -34
  87. package/templates/skills/knowledge/design-md/SKILL.md +0 -193
  88. package/templates/skills/knowledge/design-md/examples/DESIGN.md +0 -154
  89. package/templates/skills/knowledge/notebooklm-mcp/SKILL.md +0 -71
  90. package/templates/skills/knowledge/notebooklm-mcp/assets/example_asset.txt +0 -24
  91. package/templates/skills/knowledge/notebooklm-mcp/references/api_reference.md +0 -34
  92. package/templates/skills/knowledge/notebooklm-mcp/scripts/example.py +0 -19
  93. package/templates/skills/knowledge/react-components/README.md +0 -36
  94. package/templates/skills/knowledge/react-components/SKILL.md +0 -53
  95. package/templates/skills/knowledge/react-components/examples/gold-standard-card.tsx +0 -80
  96. package/templates/skills/knowledge/react-components/package-lock.json +0 -231
  97. package/templates/skills/knowledge/react-components/package.json +0 -16
  98. package/templates/skills/knowledge/react-components/resources/architecture-checklist.md +0 -15
  99. package/templates/skills/knowledge/react-components/resources/component-template.tsx +0 -37
  100. package/templates/skills/knowledge/react-components/resources/stitch-api-reference.md +0 -14
  101. package/templates/skills/knowledge/react-components/resources/style-guide.json +0 -27
  102. package/templates/skills/knowledge/react-components/scripts/fetch-stitch.sh +0 -30
  103. package/templates/skills/knowledge/react-components/scripts/validate.js +0 -68
  104. package/templates/skills/knowledge/self-update/SKILL.md +0 -60
  105. package/templates/skills/knowledge/self-update/scripts/update_kit.py +0 -103
  106. package/templates/skills/knowledge/stitch-loop/README.md +0 -54
  107. package/templates/skills/knowledge/stitch-loop/SKILL.md +0 -235
  108. package/templates/skills/knowledge/stitch-loop/examples/SITE.md +0 -73
  109. package/templates/skills/knowledge/stitch-loop/examples/next-prompt.md +0 -25
  110. package/templates/skills/knowledge/stitch-loop/resources/baton-schema.md +0 -61
  111. package/templates/skills/knowledge/stitch-loop/resources/site-template.md +0 -104
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Jira API Client Utility
4
+
5
+ Provides a reusable client for Jira REST API operations.
6
+ Handles authentication, error handling, and common operations.
7
+
8
+ This module is used by all other Jira scripts.
9
+
10
+ Usage:
11
+ from jira_client import JiraClient
12
+
13
+ client = JiraClient()
14
+ issue = client.get_issue("PROJ-123")
15
+
16
+ Environment Variables Required:
17
+ JIRA_URL - Base URL (e.g., https://company.atlassian.net)
18
+ JIRA_EMAIL - User email
19
+ JIRA_API_TOKEN - API token
20
+ """
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any, Dict, List, Optional, Tuple
28
+ from urllib.parse import urljoin
29
+
30
+ try:
31
+ import requests
32
+ except ImportError:
33
+ print("❌ Error: requests library required. Install with: pip install requests", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+ # Try to load dotenv if available
37
+ try:
38
+ from dotenv import load_dotenv
39
+ # Look for .env in common locations
40
+ for env_path in ['.env', '../.env', '../../.env', Path.home() / '.env']:
41
+ if Path(env_path).exists():
42
+ load_dotenv(env_path)
43
+ break
44
+ except ImportError:
45
+ pass
46
+
47
+
48
+ class JiraClient:
49
+ """
50
+ Jira REST API client with authentication and error handling.
51
+ """
52
+
53
+ def __init__(self, url: str = None, email: str = None, token: str = None):
54
+ """
55
+ Initialize the Jira client.
56
+
57
+ Args:
58
+ url: Jira instance URL (or JIRA_URL env var)
59
+ email: User email (or JIRA_EMAIL env var)
60
+ token: API token (or JIRA_API_TOKEN env var)
61
+ """
62
+ self.base_url = (url or os.getenv('JIRA_URL', '')).rstrip('/')
63
+ self.email = email or os.getenv('JIRA_EMAIL', '')
64
+ self.token = token or os.getenv('JIRA_API_TOKEN', '')
65
+
66
+ if not all([self.base_url, self.token]):
67
+ missing = []
68
+ if not self.base_url:
69
+ missing.append('JIRA_URL')
70
+ if not self.token:
71
+ missing.append('JIRA_API_TOKEN')
72
+ raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
73
+
74
+ # Detect Jira Server vs Cloud and set appropriate API version
75
+ # Jira Server uses /rest/api/2, Jira Cloud uses /rest/api/3
76
+ self.is_server = 'atlassian.net' not in self.base_url.lower()
77
+ api_version = '2' if self.is_server else '3'
78
+
79
+ # Clean base URL (remove /secure/Dashboard.jspa or similar paths)
80
+ import re
81
+ self.base_url = re.sub(r'/secure/.*$', '', self.base_url)
82
+ self.base_url = re.sub(r'/browse/.*$', '', self.base_url)
83
+ self.base_url = self.base_url.rstrip('/')
84
+
85
+ self.api_url = f"{self.base_url}/rest/api/{api_version}"
86
+ self.session = requests.Session()
87
+
88
+ # Jira Server can use token directly as Bearer or basic auth
89
+ # Try Bearer token first for server, basic auth for cloud
90
+ if self.is_server:
91
+ self.session.headers.update({
92
+ 'Authorization': f'Bearer {self.token}'
93
+ })
94
+ else:
95
+ if self.email:
96
+ self.session.auth = (self.email, self.token)
97
+ else:
98
+ self.session.headers.update({
99
+ 'Authorization': f'Bearer {self.token}'
100
+ })
101
+
102
+ self.session.headers.update({
103
+ 'Accept': 'application/json',
104
+ 'Content-Type': 'application/json'
105
+ })
106
+
107
+ def _request(self, method: str, endpoint: str, **kwargs) -> Tuple[bool, Any]:
108
+ """
109
+ Make an API request.
110
+
111
+ Returns:
112
+ (success, data_or_error)
113
+ """
114
+ url = f"{self.api_url}/{endpoint.lstrip('/')}"
115
+
116
+ try:
117
+ response = self.session.request(method, url, **kwargs)
118
+
119
+ if response.status_code == 204:
120
+ return True, None
121
+
122
+ if response.status_code >= 400:
123
+ error_data = response.json() if response.content else {}
124
+ error_msg = self._parse_error(error_data, response.status_code)
125
+ return False, error_msg
126
+
127
+ return True, response.json() if response.content else None
128
+
129
+ except requests.exceptions.RequestException as e:
130
+ return False, f"Request failed: {str(e)}"
131
+ except json.JSONDecodeError:
132
+ return False, f"Invalid JSON response: {response.text[:200]}"
133
+
134
+ def _parse_error(self, error_data: Dict, status_code: int) -> str:
135
+ """Parse Jira error response into readable message."""
136
+ if 'errorMessages' in error_data and error_data['errorMessages']:
137
+ return '; '.join(error_data['errorMessages'])
138
+ if 'errors' in error_data:
139
+ return '; '.join(f"{k}: {v}" for k, v in error_data['errors'].items())
140
+ return f"HTTP {status_code} error"
141
+
142
+ # ========== Issue Operations ==========
143
+
144
+ def get_issue(self, key: str, fields: List[str] = None, expand: List[str] = None) -> Tuple[bool, Any]:
145
+ """Get issue by key."""
146
+ params = {}
147
+ if fields:
148
+ params['fields'] = ','.join(fields)
149
+ if expand:
150
+ params['expand'] = ','.join(expand)
151
+
152
+ return self._request('GET', f'issue/{key}', params=params)
153
+
154
+ def create_issue(self, fields: Dict) -> Tuple[bool, Any]:
155
+ """Create a new issue."""
156
+ return self._request('POST', 'issue', json={'fields': fields})
157
+
158
+ def update_issue(self, key: str, fields: Dict = None, update: Dict = None) -> Tuple[bool, Any]:
159
+ """Update an existing issue."""
160
+ data = {}
161
+ if fields:
162
+ data['fields'] = fields
163
+ if update:
164
+ data['update'] = update
165
+
166
+ return self._request('PUT', f'issue/{key}', json=data)
167
+
168
+ def delete_issue(self, key: str) -> Tuple[bool, Any]:
169
+ """Delete an issue."""
170
+ return self._request('DELETE', f'issue/{key}')
171
+
172
+ def search_issues(self, jql: str, fields: List[str] = None,
173
+ max_results: int = 50, start_at: int = 0) -> Tuple[bool, Any]:
174
+ """Search issues using JQL."""
175
+ data = {
176
+ 'jql': jql,
177
+ 'maxResults': max_results,
178
+ 'startAt': start_at
179
+ }
180
+ if fields:
181
+ data['fields'] = fields
182
+
183
+ return self._request('POST', 'search', json=data)
184
+
185
+ # ========== Transitions ==========
186
+
187
+ def get_transitions(self, key: str) -> Tuple[bool, Any]:
188
+ """Get available transitions for an issue."""
189
+ return self._request('GET', f'issue/{key}/transitions')
190
+
191
+ def transition_issue(self, key: str, transition_id: str,
192
+ fields: Dict = None, comment: str = None) -> Tuple[bool, Any]:
193
+ """Transition an issue to a new status."""
194
+ data = {
195
+ 'transition': {'id': transition_id}
196
+ }
197
+ if fields:
198
+ data['fields'] = fields
199
+ if comment:
200
+ data['update'] = {
201
+ 'comment': [{
202
+ 'add': {'body': self._format_body(comment)}
203
+ }]
204
+ }
205
+
206
+ return self._request('POST', f'issue/{key}/transitions', json=data)
207
+
208
+ def find_transition_by_name(self, key: str, status_name: str) -> Optional[str]:
209
+ """Find transition ID by target status name."""
210
+ success, transitions = self.get_transitions(key)
211
+ if not success:
212
+ return None
213
+
214
+ status_lower = status_name.lower()
215
+ for t in transitions.get('transitions', []):
216
+ if t.get('to', {}).get('name', '').lower() == status_lower:
217
+ return t['id']
218
+ if t.get('name', '').lower() == status_lower:
219
+ return t['id']
220
+
221
+ return None
222
+
223
+ # ========== Comments ==========
224
+
225
+ def get_comments(self, key: str) -> Tuple[bool, Any]:
226
+ """Get all comments for an issue."""
227
+ return self._request('GET', f'issue/{key}/comment')
228
+
229
+ def add_comment(self, key: str, body: str, visibility: Dict = None) -> Tuple[bool, Any]:
230
+ """Add a comment to an issue."""
231
+ data = {
232
+ 'body': self._format_body(body)
233
+ }
234
+ if visibility:
235
+ data['visibility'] = visibility
236
+
237
+ return self._request('POST', f'issue/{key}/comment', json=data)
238
+
239
+ def update_comment(self, key: str, comment_id: str, body: str) -> Tuple[bool, Any]:
240
+ """Update an existing comment."""
241
+ data = {
242
+ 'body': self._format_body(body)
243
+ }
244
+ return self._request('PUT', f'issue/{key}/comment/{comment_id}', json=data)
245
+
246
+ def delete_comment(self, key: str, comment_id: str) -> Tuple[bool, Any]:
247
+ """Delete a comment."""
248
+ return self._request('DELETE', f'issue/{key}/comment/{comment_id}')
249
+
250
+ # ========== Worklog ==========
251
+
252
+ def get_worklogs(self, key: str) -> Tuple[bool, Any]:
253
+ """Get all worklogs for an issue."""
254
+ return self._request('GET', f'issue/{key}/worklog')
255
+
256
+ def add_worklog(self, key: str, time_spent: str,
257
+ comment: str = None, started: str = None,
258
+ adjust_estimate: str = None, new_estimate: str = None) -> Tuple[bool, Any]:
259
+ """
260
+ Add a worklog entry.
261
+
262
+ Args:
263
+ key: Issue key
264
+ time_spent: Time in Jira format (e.g., "2h", "1d", "30m")
265
+ comment: Work description
266
+ started: Start datetime in ISO format
267
+ adjust_estimate: How to adjust estimate (new, manual, leave, auto)
268
+ new_estimate: New estimate if adjust_estimate is 'new'
269
+ """
270
+ data = {
271
+ 'timeSpent': time_spent
272
+ }
273
+ if comment:
274
+ data['comment'] = self._format_body(comment)
275
+ if started:
276
+ data['started'] = started
277
+
278
+ params = {}
279
+ if adjust_estimate:
280
+ params['adjustEstimate'] = adjust_estimate
281
+ if new_estimate:
282
+ params['newEstimate'] = new_estimate
283
+
284
+ return self._request('POST', f'issue/{key}/worklog', json=data, params=params)
285
+
286
+ # ========== Projects ==========
287
+
288
+ def get_projects(self) -> Tuple[bool, Any]:
289
+ """Get all accessible projects."""
290
+ return self._request('GET', 'project')
291
+
292
+ def get_project(self, key: str) -> Tuple[bool, Any]:
293
+ """Get project details."""
294
+ return self._request('GET', f'project/{key}')
295
+
296
+ # ========== Users ==========
297
+
298
+ def search_users(self, query: str, max_results: int = 50) -> Tuple[bool, Any]:
299
+ """Search for users."""
300
+ return self._request('GET', 'user/search', params={
301
+ 'query': query,
302
+ 'maxResults': max_results
303
+ })
304
+
305
+ def get_myself(self) -> Tuple[bool, Any]:
306
+ """Get current user info."""
307
+ return self._request('GET', 'myself')
308
+
309
+ # ========== Helpers ==========
310
+
311
+ def _format_body(self, text: str) -> Any:
312
+ """
313
+ Format text for the appropriate Jira version.
314
+ Jira Server (v2) expects plain string.
315
+ Jira Cloud (v3) expects ADF (Atlassian Document Format).
316
+ """
317
+ if self.is_server:
318
+ return text
319
+
320
+ # Atlassian Document Format (ADF) for Jira Cloud
321
+ paragraphs = []
322
+ for para in text.split('\n\n'):
323
+ if para.strip():
324
+ paragraphs.append({
325
+ 'type': 'paragraph',
326
+ 'content': [{'type': 'text', 'text': para.strip()}]
327
+ })
328
+
329
+ return {
330
+ 'type': 'doc',
331
+ 'version': 1,
332
+ 'content': paragraphs if paragraphs else [
333
+ {'type': 'paragraph', 'content': [{'type': 'text', 'text': text}]}
334
+ ]
335
+ }
336
+
337
+ def parse_time_to_seconds(self, time_str: str) -> int:
338
+ """
339
+ Parse Jira time format to seconds.
340
+
341
+ Supports: 1w, 2d, 3h, 30m (and combinations like "2h 30m")
342
+ """
343
+ total = 0
344
+ patterns = {
345
+ 'w': 5 * 8 * 60 * 60, # 1 week = 5 days
346
+ 'd': 8 * 60 * 60, # 1 day = 8 hours
347
+ 'h': 60 * 60,
348
+ 'm': 60,
349
+ 's': 1
350
+ }
351
+
352
+ for match in re.finditer(r'(\d+)\s*([wdhms])', time_str.lower()):
353
+ value, unit = int(match.group(1)), match.group(2)
354
+ total += value * patterns.get(unit, 0)
355
+
356
+ return total
357
+
358
+
359
+ def get_client() -> JiraClient:
360
+ """Get a configured Jira client instance."""
361
+ try:
362
+ return JiraClient()
363
+ except ValueError as e:
364
+ print(f"❌ Configuration Error: {e}", file=sys.stderr)
365
+ print("\nEnsure these environment variables are set:", file=sys.stderr)
366
+ print(" JIRA_URL=https://your-domain.atlassian.net", file=sys.stderr)
367
+ print(" JIRA_EMAIL=your-email@example.com", file=sys.stderr)
368
+ print(" JIRA_API_TOKEN=your-api-token", file=sys.stderr)
369
+ sys.exit(1)
370
+
371
+
372
+ if __name__ == '__main__':
373
+ # Test connection when run directly
374
+ print("Testing Jira connection...")
375
+ client = get_client()
376
+
377
+ success, result = client.get_myself()
378
+ if success:
379
+ print(f"✅ Connected as: {result.get('displayName', 'Unknown')}")
380
+ print(f" Email: {result.get('emailAddress', 'N/A')}")
381
+ else:
382
+ print(f"❌ Connection failed: {result}")
383
+ sys.exit(1)
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Log Work Time to Jira Ticket
4
+
5
+ Logs work time against an existing Jira issue.
6
+
7
+ Usage:
8
+ python log_work.py --ticket <key> --time <duration> [options]
9
+
10
+ Arguments:
11
+ --ticket Ticket key (required)
12
+ --time Time spent: "2h", "30m", "1d", "2h 30m" (required)
13
+ --comment Work description (optional)
14
+ --started Start time in ISO format (optional)
15
+ --remaining New remaining estimate (optional)
16
+
17
+ Exit Codes:
18
+ 0 - Success
19
+ 1 - Invalid arguments
20
+ 2 - Ticket not found
21
+ 3 - Worklog error
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import sys
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+
31
+ sys.path.insert(0, str(Path(__file__).parent))
32
+ from jira_client import get_client
33
+
34
+
35
+ def round_to_15min_blocks(time_str: str) -> str:
36
+ """
37
+ Round time to 15-minute blocks (EC Jira requirement).
38
+
39
+ Args:
40
+ time_str: Time string like "2h", "25m", "1h 20m"
41
+
42
+ Returns:
43
+ Rounded time string using 15m/30m/45m/1h increments
44
+ """
45
+ # Parse time to total minutes
46
+ total_minutes = 0
47
+ patterns = {
48
+ 'w': 5 * 8 * 60, # 1 week = 5 days = 40 hours
49
+ 'd': 8 * 60, # 1 day = 8 hours
50
+ 'h': 60,
51
+ 'm': 1
52
+ }
53
+
54
+ for match in re.finditer(r'(\d+)\s*([wdhm])', time_str.lower()):
55
+ value, unit = int(match.group(1)), match.group(2)
56
+ total_minutes += value * patterns.get(unit, 0)
57
+
58
+ if total_minutes == 0:
59
+ return time_str # Return original if parsing failed
60
+
61
+ # Round to nearest 15 minutes (minimum 15 min)
62
+ rounded_minutes = max(15, round(total_minutes / 15) * 15)
63
+
64
+ # Convert back to Jira format
65
+ hours = rounded_minutes // 60
66
+ mins = rounded_minutes % 60
67
+
68
+ if hours > 0 and mins > 0:
69
+ return f"{hours}h {mins}m"
70
+ elif hours > 0:
71
+ return f"{hours}h"
72
+ else:
73
+ return f"{mins}m"
74
+
75
+
76
+ def main():
77
+ parser = argparse.ArgumentParser(
78
+ description='Log work time to a Jira ticket',
79
+ formatter_class=argparse.RawDescriptionHelpFormatter,
80
+ epilog=__doc__
81
+ )
82
+ parser.add_argument('--ticket', required=True, help='Ticket key')
83
+ parser.add_argument('--time', required=True, help='Time spent (e.g., "2h", "1d")')
84
+ parser.add_argument('--comment', help='Work description')
85
+ parser.add_argument('--started', help='Start time (ISO format)')
86
+ parser.add_argument('--remaining', help='New remaining estimate')
87
+ args = parser.parse_args()
88
+
89
+ client = get_client()
90
+ ticket = args.ticket.upper()
91
+
92
+ # Round to 15-minute blocks (EC requirement)
93
+ original_time = args.time
94
+ rounded_time = round_to_15min_blocks(original_time)
95
+
96
+ if original_time != rounded_time:
97
+ print(f"⏱️ Rounding {original_time} → {rounded_time} (15-min blocks)", file=sys.stderr)
98
+
99
+ print(f"⏱️ Logging {rounded_time} to {ticket}...", file=sys.stderr)
100
+
101
+ # Verify ticket exists
102
+ success, issue = client.get_issue(ticket)
103
+ if not success:
104
+ print(f"❌ Error: Could not find ticket {ticket}: {issue}", file=sys.stderr)
105
+ sys.exit(2)
106
+
107
+ # Parse started time or use now
108
+ started = args.started
109
+ if not started:
110
+ # Jira expects format: 2026-01-23T14:00:00.000+0100
111
+ started = datetime.now().strftime('%Y-%m-%dT%H:%M:%S.000%z')
112
+ if not started.endswith('+') and len(started) < 30:
113
+ # Add timezone if missing
114
+ started = datetime.now().astimezone().strftime('%Y-%m-%dT%H:%M:%S.000%z')
115
+
116
+ # Determine estimate adjustment
117
+ adjust_estimate = None
118
+ new_estimate = None
119
+ if args.remaining:
120
+ adjust_estimate = 'new'
121
+ new_estimate = args.remaining
122
+
123
+ # Add worklog with rounded time
124
+ success, result = client.add_worklog(
125
+ ticket,
126
+ time_spent=rounded_time,
127
+ comment=args.comment,
128
+ started=started,
129
+ adjust_estimate=adjust_estimate,
130
+ new_estimate=new_estimate
131
+ )
132
+
133
+ if not success:
134
+ print(f"❌ Error logging work: {result}", file=sys.stderr)
135
+ sys.exit(3)
136
+
137
+ worklog_id = result.get('id', 'Unknown')
138
+ time_logged = result.get('timeSpent', args.time)
139
+
140
+ output = {
141
+ 'success': True,
142
+ 'ticket': ticket,
143
+ 'worklog_id': worklog_id,
144
+ 'time_logged': time_logged,
145
+ 'started': started
146
+ }
147
+ print(json.dumps(output, indent=2))
148
+
149
+ print(f"✅ Logged {time_logged} to {ticket}", file=sys.stderr)
150
+ sys.exit(0)
151
+
152
+
153
+ if __name__ == '__main__':
154
+ main()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Search Jira Tickets
4
+
5
+ Search and filter Jira issues using JQL.
6
+
7
+ Usage:
8
+ python search_tickets.py --jql <query> [options]
9
+
10
+ Arguments:
11
+ --jql JQL query string (required)
12
+ --fields Comma-separated fields to return
13
+ --max-results Maximum results (default: 50)
14
+ --output Output format: json, table, keys (default: table)
15
+
16
+ Exit Codes:
17
+ 0 - Success
18
+ 1 - Invalid arguments
19
+ 2 - Search error
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).parent))
28
+ from jira_client import get_client
29
+
30
+
31
+ def format_table(issues: list) -> str:
32
+ """Format issues as a simple table."""
33
+ if not issues:
34
+ return "No issues found."
35
+
36
+ lines = []
37
+ lines.append(f"{'Key':<12} {'Type':<10} {'Priority':<10} {'Status':<15} {'Summary'}")
38
+ lines.append("-" * 80)
39
+
40
+ for issue in issues:
41
+ key = issue.get('key', '')
42
+ fields = issue.get('fields', {})
43
+
44
+ issue_type = fields.get('issuetype', {}).get('name', '-')[:10]
45
+ priority = fields.get('priority', {}).get('name', '-')[:10] if fields.get('priority') else '-'
46
+ status = fields.get('status', {}).get('name', '-')[:15]
47
+ summary = fields.get('summary', '')[:50]
48
+
49
+ lines.append(f"{key:<12} {issue_type:<10} {priority:<10} {status:<15} {summary}")
50
+
51
+ return '\n'.join(lines)
52
+
53
+
54
+ def main():
55
+ parser = argparse.ArgumentParser(
56
+ description='Search Jira tickets with JQL',
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ epilog=__doc__
59
+ )
60
+ parser.add_argument('--jql', required=True, help='JQL query')
61
+ parser.add_argument('--fields', help='Comma-separated fields')
62
+ parser.add_argument('--max-results', type=int, default=50, help='Max results')
63
+ parser.add_argument('--output', choices=['json', 'table', 'keys'], default='table',
64
+ help='Output format')
65
+ args = parser.parse_args()
66
+
67
+ client = get_client()
68
+
69
+ print(f"🔍 Searching: {args.jql}", file=sys.stderr)
70
+
71
+ # Parse fields
72
+ fields = None
73
+ if args.fields:
74
+ fields = [f.strip() for f in args.fields.split(',')]
75
+ else:
76
+ # Default useful fields
77
+ fields = ['summary', 'status', 'priority', 'issuetype', 'assignee', 'created', 'updated']
78
+
79
+ # Search
80
+ success, result = client.search_issues(args.jql, fields=fields, max_results=args.max_results)
81
+
82
+ if not success:
83
+ print(f"❌ Search error: {result}", file=sys.stderr)
84
+ sys.exit(2)
85
+
86
+ issues = result.get('issues', [])
87
+ total = result.get('total', len(issues))
88
+
89
+ print(f" Found {total} issue(s) (showing {len(issues)})", file=sys.stderr)
90
+
91
+ # Output based on format
92
+ if args.output == 'keys':
93
+ for issue in issues:
94
+ print(issue.get('key'))
95
+ elif args.output == 'json':
96
+ print(json.dumps(issues, indent=2))
97
+ else:
98
+ print(format_table(issues))
99
+
100
+ sys.exit(0)
101
+
102
+
103
+ if __name__ == '__main__':
104
+ main()