mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""User-specific ticket management tools.
|
|
2
|
+
|
|
3
|
+
This module provides tools for managing tickets from a user's perspective,
|
|
4
|
+
including transitioning tickets through workflow states with validation.
|
|
5
|
+
|
|
6
|
+
Design Decision: Workflow State Validation
|
|
7
|
+
------------------------------------------
|
|
8
|
+
State transitions are validated using TicketState.can_transition_to() to ensure
|
|
9
|
+
tickets follow the defined workflow. This prevents invalid state changes that
|
|
10
|
+
could break integrations or confuse team members.
|
|
11
|
+
|
|
12
|
+
Valid workflow transitions:
|
|
13
|
+
- OPEN → IN_PROGRESS, WAITING, BLOCKED, CLOSED
|
|
14
|
+
- IN_PROGRESS → READY, WAITING, BLOCKED, OPEN
|
|
15
|
+
- READY → TESTED, IN_PROGRESS, BLOCKED
|
|
16
|
+
- TESTED → DONE, IN_PROGRESS
|
|
17
|
+
- DONE → CLOSED
|
|
18
|
+
- WAITING/BLOCKED → OPEN, IN_PROGRESS, CLOSED
|
|
19
|
+
- CLOSED → (no transitions, terminal state)
|
|
20
|
+
|
|
21
|
+
Performance Considerations:
|
|
22
|
+
- State transition validation is O(1) lookup in predefined state machine
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ....core.adapter import BaseAdapter
|
|
28
|
+
from ....core.models import TicketState
|
|
29
|
+
from ....core.state_matcher import get_state_matcher
|
|
30
|
+
from ..server_sdk import get_adapter, mcp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_adapter_metadata(
|
|
34
|
+
adapter: BaseAdapter,
|
|
35
|
+
ticket_id: str | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Build adapter metadata for MCP responses."""
|
|
38
|
+
metadata = {
|
|
39
|
+
"adapter": adapter.adapter_type,
|
|
40
|
+
"adapter_name": adapter.adapter_display_name,
|
|
41
|
+
}
|
|
42
|
+
if ticket_id:
|
|
43
|
+
metadata["ticket_id"] = ticket_id
|
|
44
|
+
return metadata
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
async def get_available_transitions(ticket_id: str) -> dict[str, Any]:
|
|
49
|
+
"""Get valid next states for ticket based on workflow state machine.
|
|
50
|
+
|
|
51
|
+
Args: ticket_id (required)
|
|
52
|
+
Returns: TransitionResponse with current_state, available_transitions, transition_descriptions, is_terminal
|
|
53
|
+
See: docs/ticket-workflows.md#valid-state-transitions
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
# Get ticket from adapter
|
|
57
|
+
adapter = get_adapter()
|
|
58
|
+
ticket = await adapter.read(ticket_id)
|
|
59
|
+
|
|
60
|
+
if ticket is None:
|
|
61
|
+
return {
|
|
62
|
+
"status": "error",
|
|
63
|
+
"error": f"Ticket {ticket_id} not found",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Get current state
|
|
67
|
+
current_state = ticket.state
|
|
68
|
+
|
|
69
|
+
# Get valid transitions from state machine
|
|
70
|
+
valid_transitions = TicketState.valid_transitions()
|
|
71
|
+
# Handle both TicketState enum and string values
|
|
72
|
+
if isinstance(current_state, str):
|
|
73
|
+
current_state = TicketState(current_state)
|
|
74
|
+
available = valid_transitions.get(current_state, [])
|
|
75
|
+
|
|
76
|
+
# Create human-readable descriptions
|
|
77
|
+
descriptions = {
|
|
78
|
+
TicketState.OPEN: "Move to backlog (not yet started)",
|
|
79
|
+
TicketState.IN_PROGRESS: "Begin active work on ticket",
|
|
80
|
+
TicketState.READY: "Mark as complete and ready for review/testing",
|
|
81
|
+
TicketState.TESTED: "Mark as tested and verified",
|
|
82
|
+
TicketState.DONE: "Mark as complete and accepted",
|
|
83
|
+
TicketState.WAITING: "Pause work while waiting for external dependency",
|
|
84
|
+
TicketState.BLOCKED: "Work is blocked by an impediment",
|
|
85
|
+
TicketState.CLOSED: "Close and archive ticket (final state)",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
transition_descriptions = {
|
|
89
|
+
state.value: descriptions.get(state, "") for state in available
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"status": "completed",
|
|
94
|
+
**_build_adapter_metadata(adapter, ticket_id),
|
|
95
|
+
"current_state": current_state.value,
|
|
96
|
+
"available_transitions": [state.value for state in available],
|
|
97
|
+
"transition_descriptions": transition_descriptions,
|
|
98
|
+
"is_terminal": len(available) == 0,
|
|
99
|
+
}
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return {
|
|
102
|
+
"status": "error",
|
|
103
|
+
"error": f"Failed to get available transitions: {str(e)}",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@mcp.tool()
|
|
108
|
+
async def ticket_transition(
|
|
109
|
+
ticket_id: str,
|
|
110
|
+
to_state: str,
|
|
111
|
+
comment: str | None = None,
|
|
112
|
+
auto_confirm: bool = True,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Move ticket through workflow with validation and semantic matching (natural language support).
|
|
115
|
+
|
|
116
|
+
Args: ticket_id (required), to_state (supports natural language like "working on it"), comment (optional), auto_confirm (default: True)
|
|
117
|
+
Returns: TransitionResponse with status, ticket, previous_state, new_state, matched_state, confidence, suggestions (if ambiguous)
|
|
118
|
+
See: docs/ticket-workflows.md#semantic-state-matching, docs/ticket-workflows.md#valid-state-transitions
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
# Get ticket from adapter
|
|
122
|
+
adapter = get_adapter()
|
|
123
|
+
ticket = await adapter.read(ticket_id)
|
|
124
|
+
|
|
125
|
+
if ticket is None:
|
|
126
|
+
return {
|
|
127
|
+
"status": "error",
|
|
128
|
+
"error": f"Ticket {ticket_id} not found",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Store current state for response
|
|
132
|
+
current_state = ticket.state
|
|
133
|
+
# Handle both TicketState enum and string values
|
|
134
|
+
if isinstance(current_state, str):
|
|
135
|
+
current_state = TicketState(current_state)
|
|
136
|
+
|
|
137
|
+
# Use semantic matcher to resolve target state
|
|
138
|
+
matcher = get_state_matcher()
|
|
139
|
+
match_result = matcher.match_state(to_state)
|
|
140
|
+
|
|
141
|
+
# Build response with semantic match info
|
|
142
|
+
response: dict[str, Any] = {
|
|
143
|
+
"ticket_id": ticket_id,
|
|
144
|
+
"original_input": to_state,
|
|
145
|
+
"matched_state": match_result.state.value,
|
|
146
|
+
"confidence": match_result.confidence,
|
|
147
|
+
"match_type": match_result.match_type,
|
|
148
|
+
"current_state": current_state.value,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Handle low confidence - provide suggestions
|
|
152
|
+
if match_result.is_low_confidence():
|
|
153
|
+
suggestions = matcher.suggest_states(to_state, top_n=3)
|
|
154
|
+
return {
|
|
155
|
+
**response,
|
|
156
|
+
"status": "ambiguous",
|
|
157
|
+
"message": "Input is ambiguous. Please choose from suggestions.",
|
|
158
|
+
"suggestions": [
|
|
159
|
+
{
|
|
160
|
+
"state": s.state.value,
|
|
161
|
+
"confidence": s.confidence,
|
|
162
|
+
"description": _get_state_description(s.state),
|
|
163
|
+
}
|
|
164
|
+
for s in suggestions
|
|
165
|
+
],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Handle medium confidence - needs confirmation unless auto_confirm
|
|
169
|
+
if match_result.is_medium_confidence() and not auto_confirm:
|
|
170
|
+
return {
|
|
171
|
+
**response,
|
|
172
|
+
"status": "needs_confirmation",
|
|
173
|
+
"message": f"Matched '{to_state}' to '{match_result.state.value}' with {match_result.confidence:.0%} confidence. Please confirm.",
|
|
174
|
+
"confirm_required": True,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
target_state = match_result.state
|
|
178
|
+
|
|
179
|
+
# Validate transition using adapter (includes parent/child state constraints)
|
|
180
|
+
is_valid = await adapter.validate_transition(ticket_id, target_state)
|
|
181
|
+
if not is_valid:
|
|
182
|
+
# Check if it's a workflow violation or parent constraint violation
|
|
183
|
+
workflow_valid = current_state.can_transition_to(target_state)
|
|
184
|
+
valid_transitions = TicketState.valid_transitions().get(current_state, [])
|
|
185
|
+
valid_values = [s.value for s in valid_transitions]
|
|
186
|
+
|
|
187
|
+
if workflow_valid:
|
|
188
|
+
# Workflow is valid, so this must be a parent constraint violation
|
|
189
|
+
# Get children to determine max child state
|
|
190
|
+
from ....core.models import Task
|
|
191
|
+
|
|
192
|
+
if isinstance(ticket, Task) and ticket.children:
|
|
193
|
+
try:
|
|
194
|
+
children = await adapter.list_tasks_by_issue(ticket_id)
|
|
195
|
+
if children:
|
|
196
|
+
max_child_state = None
|
|
197
|
+
max_child_level = 0
|
|
198
|
+
for child in children:
|
|
199
|
+
child_state = child.state
|
|
200
|
+
if isinstance(child_state, str):
|
|
201
|
+
try:
|
|
202
|
+
child_state = TicketState(child_state)
|
|
203
|
+
except ValueError:
|
|
204
|
+
continue
|
|
205
|
+
child_level = child_state.completion_level()
|
|
206
|
+
if child_level > max_child_level:
|
|
207
|
+
max_child_level = child_level
|
|
208
|
+
max_child_state = child_state
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
**response,
|
|
212
|
+
"status": "error",
|
|
213
|
+
"error": f"Cannot transition to '{target_state.value}': parent issue has children in higher completion states",
|
|
214
|
+
"reason": "parent_constraint_violation",
|
|
215
|
+
"max_child_state": (
|
|
216
|
+
max_child_state.value if max_child_state else None
|
|
217
|
+
),
|
|
218
|
+
"message": f"Cannot transition to {target_state.value}: "
|
|
219
|
+
f"parent issue has children in higher completion states (max child state: {max_child_state.value if max_child_state else 'unknown'}). "
|
|
220
|
+
f"Please update child states first.",
|
|
221
|
+
"valid_transitions": valid_values,
|
|
222
|
+
}
|
|
223
|
+
except Exception:
|
|
224
|
+
# Fallback to generic message if we can't determine child states
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
# Generic parent constraint violation message
|
|
228
|
+
return {
|
|
229
|
+
**response,
|
|
230
|
+
"status": "error",
|
|
231
|
+
"error": f"Cannot transition to '{target_state.value}': parent/child state constraint violation",
|
|
232
|
+
"reason": "parent_constraint_violation",
|
|
233
|
+
"message": f"Cannot transition to {target_state.value}: "
|
|
234
|
+
f"parent issue has children in higher completion states. Please update child states first.",
|
|
235
|
+
"valid_transitions": valid_values,
|
|
236
|
+
}
|
|
237
|
+
else:
|
|
238
|
+
# Workflow violation
|
|
239
|
+
return {
|
|
240
|
+
**response,
|
|
241
|
+
"status": "error",
|
|
242
|
+
"error": f"Invalid transition from '{current_state.value}' to '{target_state.value}'",
|
|
243
|
+
"reason": "workflow_violation",
|
|
244
|
+
"valid_transitions": valid_values,
|
|
245
|
+
"message": f"Cannot transition from {current_state.value} to {target_state.value}. "
|
|
246
|
+
f"Valid transitions: {', '.join(valid_values) if valid_values else 'none (terminal state)'}",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Update ticket state
|
|
250
|
+
updated = await adapter.update(ticket_id, {"state": target_state})
|
|
251
|
+
|
|
252
|
+
if updated is None:
|
|
253
|
+
return {
|
|
254
|
+
**response,
|
|
255
|
+
"status": "error",
|
|
256
|
+
"error": f"Failed to update ticket {ticket_id}",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Add comment if provided and adapter supports it
|
|
260
|
+
comment_added = False
|
|
261
|
+
if comment and hasattr(adapter, "add_comment"):
|
|
262
|
+
try:
|
|
263
|
+
await adapter.add_comment(ticket_id, comment)
|
|
264
|
+
comment_added = True
|
|
265
|
+
except Exception:
|
|
266
|
+
# Log but don't fail the transition
|
|
267
|
+
comment_added = False
|
|
268
|
+
|
|
269
|
+
# Auto project update hook (1M-315)
|
|
270
|
+
# Trigger automatic project update if enabled and ticket has parent epic
|
|
271
|
+
auto_update_result = None
|
|
272
|
+
try:
|
|
273
|
+
from pathlib import Path
|
|
274
|
+
|
|
275
|
+
from ....automation.project_updates import AutoProjectUpdateManager
|
|
276
|
+
from ....core.project_config import ConfigResolver
|
|
277
|
+
|
|
278
|
+
# Load config
|
|
279
|
+
resolver = ConfigResolver(project_path=Path.cwd())
|
|
280
|
+
config_obj = resolver.load_project_config()
|
|
281
|
+
config_dict = config_obj.to_dict() if config_obj else {}
|
|
282
|
+
|
|
283
|
+
# Check if auto updates enabled
|
|
284
|
+
auto_updates_mgr = AutoProjectUpdateManager(config_dict, adapter)
|
|
285
|
+
if auto_updates_mgr.is_enabled():
|
|
286
|
+
# Check if ticket has parent_epic
|
|
287
|
+
parent_epic = (
|
|
288
|
+
updated.parent_epic if hasattr(updated, "parent_epic") else None
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if parent_epic:
|
|
292
|
+
# Only trigger on configured frequency
|
|
293
|
+
update_frequency = auto_updates_mgr.get_update_frequency()
|
|
294
|
+
should_trigger = update_frequency == "on_transition"
|
|
295
|
+
|
|
296
|
+
# For "on_completion", only trigger if transitioned to done/closed
|
|
297
|
+
if update_frequency == "on_completion":
|
|
298
|
+
from ....core.models import TicketState as TSEnum
|
|
299
|
+
|
|
300
|
+
should_trigger = target_state in (TSEnum.DONE, TSEnum.CLOSED)
|
|
301
|
+
|
|
302
|
+
if should_trigger:
|
|
303
|
+
auto_update_result = (
|
|
304
|
+
await auto_updates_mgr.create_transition_update(
|
|
305
|
+
ticket_id=ticket_id,
|
|
306
|
+
ticket_title=updated.title or "",
|
|
307
|
+
old_state=current_state.value,
|
|
308
|
+
new_state=target_state.value,
|
|
309
|
+
parent_epic=parent_epic,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
# Log error but don't block the transition
|
|
314
|
+
import logging
|
|
315
|
+
|
|
316
|
+
logging.getLogger(__name__).warning(
|
|
317
|
+
f"Auto project update failed (non-blocking): {e}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Build final response
|
|
321
|
+
final_response = {
|
|
322
|
+
**response,
|
|
323
|
+
**_build_adapter_metadata(adapter, ticket_id),
|
|
324
|
+
"status": "completed",
|
|
325
|
+
"ticket": updated.model_dump(),
|
|
326
|
+
"previous_state": current_state.value,
|
|
327
|
+
"new_state": target_state.value,
|
|
328
|
+
"comment_added": comment_added,
|
|
329
|
+
"message": f"Ticket {ticket_id} transitioned from {current_state.value} to {target_state.value}",
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# Include auto update result if applicable
|
|
333
|
+
if auto_update_result:
|
|
334
|
+
final_response["auto_project_update"] = auto_update_result
|
|
335
|
+
|
|
336
|
+
return final_response
|
|
337
|
+
except Exception as e:
|
|
338
|
+
return {
|
|
339
|
+
"status": "error",
|
|
340
|
+
"error": f"Failed to transition ticket: {str(e)}",
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _get_state_description(state: TicketState) -> str:
|
|
345
|
+
"""Get human-readable description of a state.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
state: TicketState to describe
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Description string
|
|
352
|
+
|
|
353
|
+
"""
|
|
354
|
+
descriptions = {
|
|
355
|
+
TicketState.OPEN: "Work not yet started, in backlog",
|
|
356
|
+
TicketState.IN_PROGRESS: "Work is actively being done",
|
|
357
|
+
TicketState.READY: "Work complete, ready for review or testing",
|
|
358
|
+
TicketState.TESTED: "Work has been tested and verified",
|
|
359
|
+
TicketState.DONE: "Work is complete and accepted",
|
|
360
|
+
TicketState.WAITING: "Work paused, waiting for external dependency",
|
|
361
|
+
TicketState.BLOCKED: "Work blocked by an impediment",
|
|
362
|
+
TicketState.CLOSED: "Ticket closed or archived (final state)",
|
|
363
|
+
}
|
|
364
|
+
return descriptions.get(state, "")
|
mcp_ticketer/queue/manager.py
CHANGED
|
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
class WorkerManager:
|
|
20
20
|
"""Manages worker process with file-based locking."""
|
|
21
21
|
|
|
22
|
-
def __init__(self):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
23
|
"""Initialize worker manager."""
|
|
24
24
|
# Lazy import to avoid circular dependency
|
|
25
25
|
from .queue import Queue
|
|
@@ -54,7 +54,7 @@ class WorkerManager:
|
|
|
54
54
|
# Lock already held
|
|
55
55
|
return False
|
|
56
56
|
|
|
57
|
-
def _release_lock(self):
|
|
57
|
+
def _release_lock(self) -> None:
|
|
58
58
|
"""Release worker lock."""
|
|
59
59
|
if hasattr(self, "lock_fd"):
|
|
60
60
|
fcntl.lockf(self.lock_fd, fcntl.LOCK_UN)
|
|
@@ -287,7 +287,7 @@ class WorkerManager:
|
|
|
287
287
|
is_running = self.is_running()
|
|
288
288
|
pid = self._get_pid() if is_running else None
|
|
289
289
|
|
|
290
|
-
status = {"running": is_running, "pid": pid}
|
|
290
|
+
status: dict[str, Any] = {"running": is_running, "pid": pid}
|
|
291
291
|
|
|
292
292
|
# Add process info if running
|
|
293
293
|
if is_running and pid:
|
|
@@ -326,7 +326,7 @@ class WorkerManager:
|
|
|
326
326
|
except (OSError, ValueError):
|
|
327
327
|
return None
|
|
328
328
|
|
|
329
|
-
def _cleanup(self):
|
|
329
|
+
def _cleanup(self) -> None:
|
|
330
330
|
"""Clean up lock and PID files."""
|
|
331
331
|
self._release_lock()
|
|
332
332
|
if self.pid_file.exists():
|
mcp_ticketer/queue/queue.py
CHANGED
|
@@ -83,7 +83,7 @@ class Queue:
|
|
|
83
83
|
self._lock = threading.Lock()
|
|
84
84
|
self._init_database()
|
|
85
85
|
|
|
86
|
-
def _init_database(self):
|
|
86
|
+
def _init_database(self) -> None:
|
|
87
87
|
"""Initialize database schema."""
|
|
88
88
|
with sqlite3.connect(self.db_path) as conn:
|
|
89
89
|
conn.execute(
|
|
@@ -462,7 +462,7 @@ class Queue:
|
|
|
462
462
|
|
|
463
463
|
return cursor.fetchone()[0]
|
|
464
464
|
|
|
465
|
-
def cleanup_old(self, days: int = 7):
|
|
465
|
+
def cleanup_old(self, days: int = 7) -> None:
|
|
466
466
|
"""Clean up old completed/failed items.
|
|
467
467
|
|
|
468
468
|
Args:
|
|
@@ -487,7 +487,7 @@ class Queue:
|
|
|
487
487
|
)
|
|
488
488
|
conn.commit()
|
|
489
489
|
|
|
490
|
-
def reset_stuck_items(self, timeout_minutes: int = 30):
|
|
490
|
+
def reset_stuck_items(self, timeout_minutes: int = 30) -> None:
|
|
491
491
|
"""Reset items stuck in processing state.
|
|
492
492
|
|
|
493
493
|
Args:
|
mcp_ticketer/queue/run_worker.py
CHANGED
|
@@ -27,7 +27,7 @@ class TicketRegistry:
|
|
|
27
27
|
self._lock = threading.Lock()
|
|
28
28
|
self._init_database()
|
|
29
29
|
|
|
30
|
-
def _init_database(self):
|
|
30
|
+
def _init_database(self) -> None:
|
|
31
31
|
"""Initialize database schema."""
|
|
32
32
|
with sqlite3.connect(self.db_path) as conn:
|
|
33
33
|
# Ticket registry table
|
|
@@ -149,7 +149,7 @@ class TicketRegistry:
|
|
|
149
149
|
with self._lock:
|
|
150
150
|
with sqlite3.connect(self.db_path) as conn:
|
|
151
151
|
update_fields = ["status = ?", "updated_at = ?"]
|
|
152
|
-
values = [status, datetime.now().isoformat()]
|
|
152
|
+
values: list[Any] = [status, datetime.now().isoformat()]
|
|
153
153
|
|
|
154
154
|
if ticket_id is not None:
|
|
155
155
|
update_fields.append("ticket_id = ?")
|
mcp_ticketer/queue/worker.py
CHANGED
|
@@ -97,12 +97,12 @@ class Worker:
|
|
|
97
97
|
f"Worker initialized with batch_size={batch_size}, max_concurrent={max_concurrent}"
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
-
def _signal_handler(self, signum, frame):
|
|
100
|
+
def _signal_handler(self, signum: int, frame: Any) -> None:
|
|
101
101
|
"""Handle shutdown signals."""
|
|
102
102
|
logger.info(f"Received signal {signum}, shutting down...")
|
|
103
103
|
self.stop()
|
|
104
104
|
|
|
105
|
-
def start(self, daemon: bool = True):
|
|
105
|
+
def start(self, daemon: bool = True) -> None:
|
|
106
106
|
"""Start the worker.
|
|
107
107
|
|
|
108
108
|
Args:
|
|
@@ -126,14 +126,14 @@ class Worker:
|
|
|
126
126
|
# Run in main thread
|
|
127
127
|
self._run_loop()
|
|
128
128
|
|
|
129
|
-
def stop(self):
|
|
129
|
+
def stop(self) -> None:
|
|
130
130
|
"""Stop the worker."""
|
|
131
131
|
logger.info("Stopping worker...")
|
|
132
132
|
self.running = False
|
|
133
133
|
self.stop_event.set()
|
|
134
134
|
|
|
135
|
-
def _run_loop(self):
|
|
136
|
-
"""
|
|
135
|
+
def _run_loop(self) -> None:
|
|
136
|
+
"""Run main worker loop with batch processing."""
|
|
137
137
|
logger.info("Worker loop started")
|
|
138
138
|
|
|
139
139
|
# Reset any stuck items on startup
|
|
@@ -174,7 +174,7 @@ class Worker:
|
|
|
174
174
|
break
|
|
175
175
|
return batch
|
|
176
176
|
|
|
177
|
-
async def _process_batch(self, batch: list[QueueItem]):
|
|
177
|
+
async def _process_batch(self, batch: list[QueueItem]) -> None:
|
|
178
178
|
"""Process a batch of queue items with concurrency control.
|
|
179
179
|
|
|
180
180
|
Args:
|
|
@@ -184,7 +184,7 @@ class Worker:
|
|
|
184
184
|
logger.info(f"Processing batch of {len(batch)} items")
|
|
185
185
|
|
|
186
186
|
# Group items by adapter for concurrent processing
|
|
187
|
-
adapter_groups = {}
|
|
187
|
+
adapter_groups: dict[str, list[Any]] = {}
|
|
188
188
|
for item in batch:
|
|
189
189
|
if item.adapter not in adapter_groups:
|
|
190
190
|
adapter_groups[item.adapter] = []
|
|
@@ -199,7 +199,9 @@ class Worker:
|
|
|
199
199
|
# Wait for all adapter groups to complete
|
|
200
200
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
201
201
|
|
|
202
|
-
async def _process_adapter_group(
|
|
202
|
+
async def _process_adapter_group(
|
|
203
|
+
self, adapter: str, items: list[QueueItem]
|
|
204
|
+
) -> None:
|
|
203
205
|
"""Process items for a specific adapter with concurrency control.
|
|
204
206
|
|
|
205
207
|
Args:
|
|
@@ -216,7 +218,7 @@ class Worker:
|
|
|
216
218
|
semaphore = self.adapter_semaphores[adapter]
|
|
217
219
|
|
|
218
220
|
# Process items with concurrency control
|
|
219
|
-
async def process_with_semaphore(item):
|
|
221
|
+
async def process_with_semaphore(item: QueueItem) -> None:
|
|
220
222
|
async with semaphore:
|
|
221
223
|
await self._process_item(item)
|
|
222
224
|
|
|
@@ -226,7 +228,7 @@ class Worker:
|
|
|
226
228
|
# Process with concurrency control
|
|
227
229
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
228
230
|
|
|
229
|
-
async def _process_item(self, item: QueueItem):
|
|
231
|
+
async def _process_item(self, item: QueueItem) -> None:
|
|
230
232
|
"""Process a single queue item.
|
|
231
233
|
|
|
232
234
|
Args:
|
|
@@ -333,7 +335,7 @@ class Worker:
|
|
|
333
335
|
self.stats["items_failed"] += 1
|
|
334
336
|
logger.error(f"Max retries exceeded for {item.id}, marking as failed")
|
|
335
337
|
|
|
336
|
-
async def _check_rate_limit(self, adapter: str):
|
|
338
|
+
async def _check_rate_limit(self, adapter: str) -> None:
|
|
337
339
|
"""Check and enforce rate limits.
|
|
338
340
|
|
|
339
341
|
Args:
|
|
@@ -357,7 +359,7 @@ class Worker:
|
|
|
357
359
|
|
|
358
360
|
self.last_request_times[adapter] = datetime.now()
|
|
359
361
|
|
|
360
|
-
def _get_adapter(self, item: QueueItem):
|
|
362
|
+
def _get_adapter(self, item: QueueItem) -> Any:
|
|
361
363
|
"""Get adapter instance for item.
|
|
362
364
|
|
|
363
365
|
Args:
|
|
@@ -442,7 +444,7 @@ class Worker:
|
|
|
442
444
|
|
|
443
445
|
return adapter
|
|
444
446
|
|
|
445
|
-
async def _execute_operation(self, adapter, item: QueueItem) -> dict[str, Any]:
|
|
447
|
+
async def _execute_operation(self, adapter: Any, item: QueueItem) -> dict[str, Any]:
|
|
446
448
|
"""Execute the queued operation.
|
|
447
449
|
|
|
448
450
|
Args:
|