claude-dev-cli 0.16.2__py3-none-any.whl → 0.18.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 claude-dev-cli might be problematic. Click here for more details.
- claude_dev_cli/__init__.py +1 -1
- claude_dev_cli/cli.py +424 -0
- claude_dev_cli/logging/__init__.py +6 -0
- claude_dev_cli/logging/logger.py +84 -0
- claude_dev_cli/logging/markdown_logger.py +131 -0
- claude_dev_cli/notifications/__init__.py +6 -0
- claude_dev_cli/notifications/notifier.py +69 -0
- claude_dev_cli/notifications/ntfy.py +87 -0
- claude_dev_cli/project/__init__.py +10 -0
- claude_dev_cli/project/bug_tracker.py +458 -0
- claude_dev_cli/project/context_gatherer.py +535 -0
- claude_dev_cli/project/executor.py +370 -0
- claude_dev_cli/tickets/__init__.py +7 -0
- claude_dev_cli/tickets/backend.py +229 -0
- claude_dev_cli/tickets/markdown.py +309 -0
- claude_dev_cli/tickets/repo_tickets.py +361 -0
- claude_dev_cli/vcs/__init__.py +6 -0
- claude_dev_cli/vcs/git.py +172 -0
- claude_dev_cli/vcs/manager.py +90 -0
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/METADATA +335 -4
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/RECORD +25 -8
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/WHEEL +0 -0
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/entry_points.txt +0 -0
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/licenses/LICENSE +0 -0
- {claude_dev_cli-0.16.2.dist-info → claude_dev_cli-0.18.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Markdown-based fallback ticket backend.
|
|
2
|
+
|
|
3
|
+
Simple file-based ticket system when external backends aren't available.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List, Dict, Any
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from claude_dev_cli.tickets.backend import TicketBackend, Ticket, Epic, Story
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MarkdownBackend(TicketBackend):
|
|
16
|
+
"""Simple markdown-based ticket system.
|
|
17
|
+
|
|
18
|
+
Stores tickets as JSON files in .cdc-tickets/ directory.
|
|
19
|
+
Provides fallback when repo-tickets or other systems aren't available.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_dir: Optional[Path] = None):
|
|
23
|
+
"""Initialize markdown backend.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_dir: Base directory for tickets (default: current directory)
|
|
27
|
+
"""
|
|
28
|
+
self.base_dir = base_dir or Path.cwd()
|
|
29
|
+
self.tickets_dir = self.base_dir / ".cdc-tickets"
|
|
30
|
+
self.epics_dir = self.tickets_dir / "epics"
|
|
31
|
+
self.stories_dir = self.tickets_dir / "stories"
|
|
32
|
+
self.tasks_dir = self.tickets_dir / "tasks"
|
|
33
|
+
|
|
34
|
+
def connect(self) -> bool:
|
|
35
|
+
"""Initialize ticket directories if needed."""
|
|
36
|
+
try:
|
|
37
|
+
self.tickets_dir.mkdir(exist_ok=True)
|
|
38
|
+
self.epics_dir.mkdir(exist_ok=True)
|
|
39
|
+
self.stories_dir.mkdir(exist_ok=True)
|
|
40
|
+
self.tasks_dir.mkdir(exist_ok=True)
|
|
41
|
+
return True
|
|
42
|
+
except OSError:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
def fetch_ticket(self, ticket_id: str) -> Optional[Ticket]:
|
|
46
|
+
"""Fetch ticket from JSON file."""
|
|
47
|
+
ticket_file = self.tasks_dir / f"{ticket_id}.json"
|
|
48
|
+
|
|
49
|
+
if not ticket_file.exists():
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(ticket_file, 'r') as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
|
|
56
|
+
return self._dict_to_ticket(data)
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def create_epic(self, title: str, description: str = "", **kwargs) -> Epic:
|
|
61
|
+
"""Create epic as JSON file."""
|
|
62
|
+
epic_id = f"EPIC-{self._generate_id()}"
|
|
63
|
+
|
|
64
|
+
epic = Epic(
|
|
65
|
+
id=epic_id,
|
|
66
|
+
title=title,
|
|
67
|
+
description=description,
|
|
68
|
+
status=kwargs.get("status", "draft"),
|
|
69
|
+
priority=kwargs.get("priority", "medium"),
|
|
70
|
+
owner=kwargs.get("owner"),
|
|
71
|
+
created_at=datetime.now()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Save to file
|
|
75
|
+
epic_file = self.epics_dir / f"{epic_id}.json"
|
|
76
|
+
with open(epic_file, 'w') as f:
|
|
77
|
+
json.dump(self._epic_to_dict(epic), f, indent=2, default=str)
|
|
78
|
+
|
|
79
|
+
return epic
|
|
80
|
+
|
|
81
|
+
def create_story(self, epic_id: str, title: str, description: str = "", **kwargs) -> Story:
|
|
82
|
+
"""Create story as JSON file."""
|
|
83
|
+
story_id = f"STORY-{self._generate_id()}"
|
|
84
|
+
|
|
85
|
+
story = Story(
|
|
86
|
+
id=story_id,
|
|
87
|
+
title=title,
|
|
88
|
+
description=description,
|
|
89
|
+
epic_id=epic_id,
|
|
90
|
+
status=kwargs.get("status", "draft"),
|
|
91
|
+
priority=kwargs.get("priority", "medium"),
|
|
92
|
+
story_points=kwargs.get("story_points")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Save to file
|
|
96
|
+
story_file = self.stories_dir / f"{story_id}.json"
|
|
97
|
+
with open(story_file, 'w') as f:
|
|
98
|
+
json.dump(self._story_to_dict(story), f, indent=2, default=str)
|
|
99
|
+
|
|
100
|
+
# Link to epic
|
|
101
|
+
epic_file = self.epics_dir / f"{epic_id}.json"
|
|
102
|
+
if epic_file.exists():
|
|
103
|
+
with open(epic_file, 'r') as f:
|
|
104
|
+
epic_data = json.load(f)
|
|
105
|
+
|
|
106
|
+
if story_id not in epic_data.get('ticket_ids', []):
|
|
107
|
+
epic_data.setdefault('ticket_ids', []).append(story_id)
|
|
108
|
+
|
|
109
|
+
with open(epic_file, 'w') as f:
|
|
110
|
+
json.dump(epic_data, f, indent=2, default=str)
|
|
111
|
+
|
|
112
|
+
return story
|
|
113
|
+
|
|
114
|
+
def create_task(self, story_id: Optional[str], title: str, description: str = "", **kwargs) -> Ticket:
|
|
115
|
+
"""Create task as JSON file."""
|
|
116
|
+
task_id = f"TASK-{self._generate_id()}"
|
|
117
|
+
|
|
118
|
+
ticket = Ticket(
|
|
119
|
+
id=task_id,
|
|
120
|
+
title=title,
|
|
121
|
+
description=description,
|
|
122
|
+
status=kwargs.get("status", "open"),
|
|
123
|
+
priority=kwargs.get("priority", "medium"),
|
|
124
|
+
ticket_type=kwargs.get("ticket_type", "feature"),
|
|
125
|
+
assignee=kwargs.get("assignee"),
|
|
126
|
+
labels=kwargs.get("labels", []),
|
|
127
|
+
story_id=story_id,
|
|
128
|
+
created_at=datetime.now()
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Save to file
|
|
132
|
+
task_file = self.tasks_dir / f"{task_id}.json"
|
|
133
|
+
with open(task_file, 'w') as f:
|
|
134
|
+
json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
|
|
135
|
+
|
|
136
|
+
return ticket
|
|
137
|
+
|
|
138
|
+
def update_ticket(self, ticket_id: str, **kwargs) -> Ticket:
|
|
139
|
+
"""Update ticket fields."""
|
|
140
|
+
ticket = self.fetch_ticket(ticket_id)
|
|
141
|
+
if not ticket:
|
|
142
|
+
raise ValueError(f"Ticket {ticket_id} not found")
|
|
143
|
+
|
|
144
|
+
# Update fields
|
|
145
|
+
for key, value in kwargs.items():
|
|
146
|
+
if hasattr(ticket, key):
|
|
147
|
+
setattr(ticket, key, value)
|
|
148
|
+
|
|
149
|
+
ticket.updated_at = datetime.now()
|
|
150
|
+
|
|
151
|
+
# Save updated ticket
|
|
152
|
+
task_file = self.tasks_dir / f"{ticket_id}.json"
|
|
153
|
+
with open(task_file, 'w') as f:
|
|
154
|
+
json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
|
|
155
|
+
|
|
156
|
+
return ticket
|
|
157
|
+
|
|
158
|
+
def list_tickets(self, status: Optional[str] = None, epic_id: Optional[str] = None,
|
|
159
|
+
**filters) -> List[Ticket]:
|
|
160
|
+
"""List all tickets with filters."""
|
|
161
|
+
tickets = []
|
|
162
|
+
|
|
163
|
+
for ticket_file in self.tasks_dir.glob("*.json"):
|
|
164
|
+
try:
|
|
165
|
+
with open(ticket_file, 'r') as f:
|
|
166
|
+
data = json.load(f)
|
|
167
|
+
|
|
168
|
+
ticket = self._dict_to_ticket(data)
|
|
169
|
+
if not ticket:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Apply filters
|
|
173
|
+
if status and ticket.status != status:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if epic_id and ticket.epic_id != epic_id:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
tickets.append(ticket)
|
|
180
|
+
|
|
181
|
+
except (json.JSONDecodeError, OSError):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
return tickets
|
|
185
|
+
|
|
186
|
+
def add_comment(self, ticket_id: str, comment: str, author: str = "") -> bool:
|
|
187
|
+
"""Add comment to ticket metadata."""
|
|
188
|
+
ticket = self.fetch_ticket(ticket_id)
|
|
189
|
+
if not ticket:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
if not ticket.metadata:
|
|
193
|
+
ticket.metadata = {}
|
|
194
|
+
|
|
195
|
+
if 'comments' not in ticket.metadata:
|
|
196
|
+
ticket.metadata['comments'] = []
|
|
197
|
+
|
|
198
|
+
ticket.metadata['comments'].append({
|
|
199
|
+
'author': author or 'unknown',
|
|
200
|
+
'text': comment,
|
|
201
|
+
'timestamp': datetime.now().isoformat()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
# Save updated ticket
|
|
205
|
+
task_file = self.tasks_dir / f"{ticket_id}.json"
|
|
206
|
+
with open(task_file, 'w') as f:
|
|
207
|
+
json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def attach_file(self, ticket_id: str, file_path: str) -> bool:
|
|
212
|
+
"""Attach file reference to ticket."""
|
|
213
|
+
ticket = self.fetch_ticket(ticket_id)
|
|
214
|
+
if not ticket:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
if file_path not in ticket.files:
|
|
218
|
+
ticket.files.append(file_path)
|
|
219
|
+
|
|
220
|
+
# Save updated ticket
|
|
221
|
+
task_file = self.tasks_dir / f"{ticket_id}.json"
|
|
222
|
+
with open(task_file, 'w') as f:
|
|
223
|
+
json.dump(self._ticket_to_dict(ticket), f, indent=2, default=str)
|
|
224
|
+
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
def _generate_id(self) -> str:
|
|
228
|
+
"""Generate unique ID."""
|
|
229
|
+
return str(uuid.uuid4())[:8].upper()
|
|
230
|
+
|
|
231
|
+
def _ticket_to_dict(self, ticket: Ticket) -> Dict[str, Any]:
|
|
232
|
+
"""Convert Ticket to dict for JSON serialization."""
|
|
233
|
+
return {
|
|
234
|
+
'id': ticket.id,
|
|
235
|
+
'title': ticket.title,
|
|
236
|
+
'description': ticket.description,
|
|
237
|
+
'status': ticket.status,
|
|
238
|
+
'priority': ticket.priority,
|
|
239
|
+
'ticket_type': ticket.ticket_type,
|
|
240
|
+
'assignee': ticket.assignee,
|
|
241
|
+
'labels': ticket.labels,
|
|
242
|
+
'epic_id': ticket.epic_id,
|
|
243
|
+
'story_id': ticket.story_id,
|
|
244
|
+
'parent_id': ticket.parent_id,
|
|
245
|
+
'requirements': ticket.requirements,
|
|
246
|
+
'acceptance_criteria': ticket.acceptance_criteria,
|
|
247
|
+
'user_stories': ticket.user_stories,
|
|
248
|
+
'files': ticket.files,
|
|
249
|
+
'metadata': ticket.metadata,
|
|
250
|
+
'created_at': ticket.created_at.isoformat() if ticket.created_at else None,
|
|
251
|
+
'updated_at': ticket.updated_at.isoformat() if ticket.updated_at else None
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
def _dict_to_ticket(self, data: Dict[str, Any]) -> Optional[Ticket]:
|
|
255
|
+
"""Convert dict to Ticket object."""
|
|
256
|
+
try:
|
|
257
|
+
return Ticket(
|
|
258
|
+
id=data['id'],
|
|
259
|
+
title=data['title'],
|
|
260
|
+
description=data.get('description', ''),
|
|
261
|
+
status=data.get('status', 'open'),
|
|
262
|
+
priority=data.get('priority', 'medium'),
|
|
263
|
+
ticket_type=data.get('ticket_type', 'feature'),
|
|
264
|
+
assignee=data.get('assignee'),
|
|
265
|
+
labels=data.get('labels', []),
|
|
266
|
+
epic_id=data.get('epic_id'),
|
|
267
|
+
story_id=data.get('story_id'),
|
|
268
|
+
parent_id=data.get('parent_id'),
|
|
269
|
+
requirements=data.get('requirements', []),
|
|
270
|
+
acceptance_criteria=data.get('acceptance_criteria', []),
|
|
271
|
+
user_stories=data.get('user_stories', []),
|
|
272
|
+
files=data.get('files', []),
|
|
273
|
+
metadata=data.get('metadata', {}),
|
|
274
|
+
created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
|
|
275
|
+
updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None
|
|
276
|
+
)
|
|
277
|
+
except (KeyError, ValueError):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
def _epic_to_dict(self, epic: Epic) -> Dict[str, Any]:
|
|
281
|
+
"""Convert Epic to dict."""
|
|
282
|
+
return {
|
|
283
|
+
'id': epic.id,
|
|
284
|
+
'title': epic.title,
|
|
285
|
+
'description': epic.description,
|
|
286
|
+
'status': epic.status,
|
|
287
|
+
'priority': epic.priority,
|
|
288
|
+
'owner': epic.owner,
|
|
289
|
+
'ticket_ids': epic.ticket_ids,
|
|
290
|
+
'goals': epic.goals,
|
|
291
|
+
'created_at': epic.created_at.isoformat() if epic.created_at else None
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def _story_to_dict(self, story: Story) -> Dict[str, Any]:
|
|
295
|
+
"""Convert Story to dict."""
|
|
296
|
+
return {
|
|
297
|
+
'id': story.id,
|
|
298
|
+
'title': story.title,
|
|
299
|
+
'description': story.description,
|
|
300
|
+
'epic_id': story.epic_id,
|
|
301
|
+
'status': story.status,
|
|
302
|
+
'priority': story.priority,
|
|
303
|
+
'story_points': story.story_points,
|
|
304
|
+
'acceptance_criteria': story.acceptance_criteria
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def get_backend_name(self) -> str:
|
|
308
|
+
"""Return backend name."""
|
|
309
|
+
return "markdown"
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""repo-tickets backend implementation for claude-dev-cli.
|
|
2
|
+
|
|
3
|
+
Integrates with repo-tickets CLI to manage tickets in VCS repositories.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from claude_dev_cli.tickets.backend import TicketBackend, Ticket, Epic, Story
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RepoTicketsBackend(TicketBackend):
|
|
16
|
+
"""Backend for repo-tickets integration.
|
|
17
|
+
|
|
18
|
+
Uses subprocess to call 'tickets' CLI commands.
|
|
19
|
+
Assumes repo-tickets is installed and initialized in current repository.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, repo_path: Optional[Path] = None):
|
|
23
|
+
"""Initialize repo-tickets backend.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
repo_path: Path to repository (default: current directory)
|
|
27
|
+
"""
|
|
28
|
+
self.repo_path = repo_path or Path.cwd()
|
|
29
|
+
self._tickets_dir = self.repo_path / ".tickets"
|
|
30
|
+
|
|
31
|
+
def connect(self) -> bool:
|
|
32
|
+
"""Verify repo-tickets is initialized and accessible."""
|
|
33
|
+
try:
|
|
34
|
+
# Check if .tickets directory exists
|
|
35
|
+
if not self._tickets_dir.exists():
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
# Try to list tickets (will fail if not properly initialized)
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["tickets", "list", "--format", "json"],
|
|
41
|
+
cwd=self.repo_path,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=5
|
|
45
|
+
)
|
|
46
|
+
return result.returncode == 0
|
|
47
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def fetch_ticket(self, ticket_id: str) -> Optional[Ticket]:
|
|
51
|
+
"""Fetch a ticket from repo-tickets."""
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["tickets", "show", ticket_id, "--format", "json"],
|
|
55
|
+
cwd=self.repo_path,
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
timeout=10
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
data = json.loads(result.stdout)
|
|
65
|
+
return self._convert_to_ticket(data)
|
|
66
|
+
|
|
67
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def create_epic(self, title: str, description: str = "", **kwargs) -> Epic:
|
|
71
|
+
"""Create an epic in repo-tickets."""
|
|
72
|
+
cmd = ["tickets", "epic", "create", title]
|
|
73
|
+
|
|
74
|
+
if description:
|
|
75
|
+
cmd.extend(["--description", description])
|
|
76
|
+
|
|
77
|
+
if "priority" in kwargs:
|
|
78
|
+
cmd.extend(["--priority", kwargs["priority"]])
|
|
79
|
+
|
|
80
|
+
if "owner" in kwargs:
|
|
81
|
+
cmd.extend(["--owner", kwargs["owner"]])
|
|
82
|
+
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
cmd,
|
|
85
|
+
cwd=self.repo_path,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=10
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
raise RuntimeError(f"Failed to create epic: {result.stderr}")
|
|
93
|
+
|
|
94
|
+
# Extract epic ID from output
|
|
95
|
+
epic_id = self._extract_id_from_output(result.stdout)
|
|
96
|
+
|
|
97
|
+
return Epic(
|
|
98
|
+
id=epic_id,
|
|
99
|
+
title=title,
|
|
100
|
+
description=description,
|
|
101
|
+
status=kwargs.get("status", "draft"),
|
|
102
|
+
priority=kwargs.get("priority", "medium"),
|
|
103
|
+
owner=kwargs.get("owner"),
|
|
104
|
+
created_at=datetime.now()
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def create_story(self, epic_id: str, title: str, description: str = "", **kwargs) -> Story:
|
|
108
|
+
"""Create a user story within an epic."""
|
|
109
|
+
# repo-tickets uses backlog items for stories
|
|
110
|
+
cmd = ["tickets", "backlog", "add", title]
|
|
111
|
+
|
|
112
|
+
if description:
|
|
113
|
+
cmd.extend(["--description", description])
|
|
114
|
+
|
|
115
|
+
if "priority" in kwargs:
|
|
116
|
+
cmd.extend(["--priority", kwargs["priority"]])
|
|
117
|
+
|
|
118
|
+
if "story_points" in kwargs:
|
|
119
|
+
cmd.extend(["--effort", str(kwargs["story_points"])])
|
|
120
|
+
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
cmd,
|
|
123
|
+
cwd=self.repo_path,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
timeout=10
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if result.returncode != 0:
|
|
130
|
+
raise RuntimeError(f"Failed to create story: {result.stderr}")
|
|
131
|
+
|
|
132
|
+
story_id = self._extract_id_from_output(result.stdout)
|
|
133
|
+
|
|
134
|
+
# Link story to epic
|
|
135
|
+
subprocess.run(
|
|
136
|
+
["tickets", "epic", "add-ticket", epic_id, story_id],
|
|
137
|
+
cwd=self.repo_path,
|
|
138
|
+
timeout=10
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return Story(
|
|
142
|
+
id=story_id,
|
|
143
|
+
title=title,
|
|
144
|
+
description=description,
|
|
145
|
+
epic_id=epic_id,
|
|
146
|
+
status=kwargs.get("status", "draft"),
|
|
147
|
+
priority=kwargs.get("priority", "medium"),
|
|
148
|
+
story_points=kwargs.get("story_points")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def create_task(self, story_id: Optional[str], title: str, description: str = "", **kwargs) -> Ticket:
|
|
152
|
+
"""Create a task ticket."""
|
|
153
|
+
cmd = ["tickets", "create", title]
|
|
154
|
+
|
|
155
|
+
if description:
|
|
156
|
+
cmd.extend(["--description", description])
|
|
157
|
+
|
|
158
|
+
if "priority" in kwargs:
|
|
159
|
+
cmd.extend(["--priority", kwargs["priority"]])
|
|
160
|
+
|
|
161
|
+
if "assignee" in kwargs:
|
|
162
|
+
cmd.extend(["--assignee", kwargs["assignee"]])
|
|
163
|
+
|
|
164
|
+
if "labels" in kwargs and kwargs["labels"]:
|
|
165
|
+
for label in kwargs["labels"]:
|
|
166
|
+
cmd.extend(["--label", label])
|
|
167
|
+
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
cmd,
|
|
170
|
+
cwd=self.repo_path,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
timeout=10
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
raise RuntimeError(f"Failed to create task: {result.stderr}")
|
|
178
|
+
|
|
179
|
+
task_id = self._extract_id_from_output(result.stdout)
|
|
180
|
+
|
|
181
|
+
# Link to story if provided
|
|
182
|
+
if story_id:
|
|
183
|
+
# In repo-tickets, we add the task to the epic that contains the story
|
|
184
|
+
# This is a simplification - could be enhanced
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
return Ticket(
|
|
188
|
+
id=task_id,
|
|
189
|
+
title=title,
|
|
190
|
+
description=description,
|
|
191
|
+
status=kwargs.get("status", "open"),
|
|
192
|
+
priority=kwargs.get("priority", "medium"),
|
|
193
|
+
ticket_type=kwargs.get("ticket_type", "feature"),
|
|
194
|
+
assignee=kwargs.get("assignee"),
|
|
195
|
+
labels=kwargs.get("labels", []),
|
|
196
|
+
story_id=story_id,
|
|
197
|
+
created_at=datetime.now()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def update_ticket(self, ticket_id: str, **kwargs) -> Ticket:
|
|
201
|
+
"""Update a ticket's fields."""
|
|
202
|
+
cmd = ["tickets", "update", ticket_id]
|
|
203
|
+
|
|
204
|
+
if "status" in kwargs:
|
|
205
|
+
cmd.extend(["--status", kwargs["status"]])
|
|
206
|
+
|
|
207
|
+
if "priority" in kwargs:
|
|
208
|
+
cmd.extend(["--priority", kwargs["priority"]])
|
|
209
|
+
|
|
210
|
+
if "assignee" in kwargs:
|
|
211
|
+
cmd.extend(["--assignee", kwargs["assignee"]])
|
|
212
|
+
|
|
213
|
+
if "description" in kwargs:
|
|
214
|
+
cmd.extend(["--description", kwargs["description"]])
|
|
215
|
+
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
cmd,
|
|
218
|
+
cwd=self.repo_path,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=10
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if result.returncode != 0:
|
|
225
|
+
raise RuntimeError(f"Failed to update ticket: {result.stderr}")
|
|
226
|
+
|
|
227
|
+
# Fetch updated ticket
|
|
228
|
+
updated = self.fetch_ticket(ticket_id)
|
|
229
|
+
if not updated:
|
|
230
|
+
raise RuntimeError(f"Ticket {ticket_id} not found after update")
|
|
231
|
+
|
|
232
|
+
return updated
|
|
233
|
+
|
|
234
|
+
def list_tickets(self, status: Optional[str] = None, epic_id: Optional[str] = None,
|
|
235
|
+
**filters) -> List[Ticket]:
|
|
236
|
+
"""List tickets with filters."""
|
|
237
|
+
cmd = ["tickets", "list", "--format", "json"]
|
|
238
|
+
|
|
239
|
+
if status:
|
|
240
|
+
cmd.extend(["--status", status])
|
|
241
|
+
|
|
242
|
+
if epic_id:
|
|
243
|
+
cmd.extend(["--epic", epic_id])
|
|
244
|
+
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
cmd,
|
|
247
|
+
cwd=self.repo_path,
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
timeout=15
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if result.returncode != 0:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(result.stdout)
|
|
258
|
+
tickets = []
|
|
259
|
+
|
|
260
|
+
if isinstance(data, list):
|
|
261
|
+
for item in data:
|
|
262
|
+
ticket = self._convert_to_ticket(item)
|
|
263
|
+
if ticket:
|
|
264
|
+
tickets.append(ticket)
|
|
265
|
+
|
|
266
|
+
return tickets
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
def add_comment(self, ticket_id: str, comment: str, author: str = "") -> bool:
|
|
271
|
+
"""Add a comment to a ticket."""
|
|
272
|
+
try:
|
|
273
|
+
cmd = ["tickets", "comment", ticket_id, comment]
|
|
274
|
+
|
|
275
|
+
if author:
|
|
276
|
+
cmd.extend(["--author", author])
|
|
277
|
+
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
cmd,
|
|
280
|
+
cwd=self.repo_path,
|
|
281
|
+
capture_output=True,
|
|
282
|
+
text=True,
|
|
283
|
+
timeout=10
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return result.returncode == 0
|
|
287
|
+
except subprocess.TimeoutExpired:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def attach_file(self, ticket_id: str, file_path: str) -> bool:
|
|
291
|
+
"""Attach a file reference to a ticket.
|
|
292
|
+
|
|
293
|
+
repo-tickets doesn't support file attachments directly,
|
|
294
|
+
so we add a comment with the file path.
|
|
295
|
+
"""
|
|
296
|
+
comment = f"📎 File attached: {file_path}"
|
|
297
|
+
return self.add_comment(ticket_id, comment, "claude-dev-cli")
|
|
298
|
+
|
|
299
|
+
def _extract_id_from_output(self, output: str) -> str:
|
|
300
|
+
"""Extract ticket/epic/story ID from command output.
|
|
301
|
+
|
|
302
|
+
Looks for patterns like "TICKET-123" or "EPIC-1"
|
|
303
|
+
"""
|
|
304
|
+
import re
|
|
305
|
+
|
|
306
|
+
# Common patterns in repo-tickets
|
|
307
|
+
patterns = [
|
|
308
|
+
r'(TICKET-\d+)',
|
|
309
|
+
r'(EPIC-\d+)',
|
|
310
|
+
r'(BACKLOG-\d+)',
|
|
311
|
+
r'(STORY-\d+)',
|
|
312
|
+
r'(TASK-\d+)'
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
for pattern in patterns:
|
|
316
|
+
match = re.search(pattern, output)
|
|
317
|
+
if match:
|
|
318
|
+
return match.group(1)
|
|
319
|
+
|
|
320
|
+
raise ValueError(f"Could not extract ID from output: {output}")
|
|
321
|
+
|
|
322
|
+
def _convert_to_ticket(self, data: Dict[str, Any]) -> Optional[Ticket]:
|
|
323
|
+
"""Convert repo-tickets JSON to unified Ticket object."""
|
|
324
|
+
try:
|
|
325
|
+
return Ticket(
|
|
326
|
+
id=data.get("id", ""),
|
|
327
|
+
title=data.get("title", ""),
|
|
328
|
+
description=data.get("description", ""),
|
|
329
|
+
status=data.get("status", "open"),
|
|
330
|
+
priority=data.get("priority", "medium"),
|
|
331
|
+
ticket_type=data.get("type", "feature"),
|
|
332
|
+
assignee=data.get("assignee"),
|
|
333
|
+
labels=data.get("labels", []),
|
|
334
|
+
epic_id=data.get("epic_id"),
|
|
335
|
+
story_id=data.get("story_id"),
|
|
336
|
+
requirements=data.get("requirements", []),
|
|
337
|
+
acceptance_criteria=data.get("acceptance_criteria", []),
|
|
338
|
+
files=data.get("files", []),
|
|
339
|
+
metadata=data.get("metadata", {}),
|
|
340
|
+
created_at=self._parse_datetime(data.get("created_at")),
|
|
341
|
+
updated_at=self._parse_datetime(data.get("updated_at"))
|
|
342
|
+
)
|
|
343
|
+
except (KeyError, ValueError):
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def _parse_datetime(self, value: Any) -> Optional[datetime]:
|
|
347
|
+
"""Parse datetime from various formats."""
|
|
348
|
+
if not value:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
if isinstance(value, datetime):
|
|
352
|
+
return value
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
return datetime.fromisoformat(str(value))
|
|
356
|
+
except (ValueError, AttributeError):
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def get_backend_name(self) -> str:
|
|
360
|
+
"""Return backend name."""
|
|
361
|
+
return "repo-tickets"
|