mcp-ticketer 0.2.0__py3-none-any.whl → 2.2.9__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -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/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- 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 +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1284
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -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 +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- 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/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- 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 +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
- mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""GitHub-specific type definitions and conversion utilities.
|
|
2
|
+
|
|
3
|
+
This module contains:
|
|
4
|
+
- State and priority mappings between GitHub and universal models
|
|
5
|
+
- Type conversion helper functions
|
|
6
|
+
- GitHub-specific constants and enums
|
|
7
|
+
- TypedDict definitions for GitHub API responses
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, TypedDict
|
|
13
|
+
|
|
14
|
+
from ...core.models import Priority, TicketState
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GitHubStateMapping:
|
|
18
|
+
"""GitHub issue states and label-based extended states.
|
|
19
|
+
|
|
20
|
+
Design Decision: GitHub's two-state model (open/closed)
|
|
21
|
+
|
|
22
|
+
GitHub natively only supports two states: 'open' and 'closed'.
|
|
23
|
+
To support richer workflow states, we use labels to extend the state model.
|
|
24
|
+
|
|
25
|
+
Rationale:
|
|
26
|
+
- Maintains compatibility with GitHub's API limitations
|
|
27
|
+
- Allows flexible workflow states through labeling
|
|
28
|
+
- Enables state transitions without closing issues
|
|
29
|
+
|
|
30
|
+
Trade-offs:
|
|
31
|
+
- State changes require label management (more API calls)
|
|
32
|
+
- Labels are user-visible and can be manually modified
|
|
33
|
+
- No built-in state transition validation in GitHub
|
|
34
|
+
|
|
35
|
+
Extension Point: Custom state labels can be configured per repository
|
|
36
|
+
through adapter configuration.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# GitHub native states
|
|
40
|
+
OPEN = "open"
|
|
41
|
+
CLOSED = "closed"
|
|
42
|
+
|
|
43
|
+
# Extended states via labels
|
|
44
|
+
# These labels represent workflow states beyond GitHub's native open/closed
|
|
45
|
+
STATE_LABELS = {
|
|
46
|
+
TicketState.IN_PROGRESS: "in-progress",
|
|
47
|
+
TicketState.READY: "ready",
|
|
48
|
+
TicketState.TESTED: "tested",
|
|
49
|
+
TicketState.WAITING: "waiting",
|
|
50
|
+
TicketState.BLOCKED: "blocked",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Priority labels mapping
|
|
54
|
+
# Multiple label patterns support different team conventions
|
|
55
|
+
PRIORITY_LABELS = {
|
|
56
|
+
Priority.CRITICAL: ["P0", "critical", "urgent"],
|
|
57
|
+
Priority.HIGH: ["P1", "high"],
|
|
58
|
+
Priority.MEDIUM: ["P2", "medium"],
|
|
59
|
+
Priority.LOW: ["P3", "low"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_universal_state(
|
|
64
|
+
github_state: str,
|
|
65
|
+
labels: list[str],
|
|
66
|
+
) -> TicketState:
|
|
67
|
+
"""Convert GitHub state + labels to universal TicketState.
|
|
68
|
+
|
|
69
|
+
GitHub has only two states (open/closed), so we use labels to infer
|
|
70
|
+
the extended workflow state.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
----
|
|
74
|
+
github_state: GitHub issue state ('open' or 'closed')
|
|
75
|
+
labels: List of label names attached to the issue
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
-------
|
|
79
|
+
Universal ticket state enum value
|
|
80
|
+
|
|
81
|
+
Performance:
|
|
82
|
+
-----------
|
|
83
|
+
Time Complexity: O(n*m) where n=number of labels, m=state labels to check
|
|
84
|
+
Worst case: ~5 state labels * ~20 issue labels = 100 comparisons
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
-------
|
|
88
|
+
>>> get_universal_state("open", ["in-progress", "bug"])
|
|
89
|
+
TicketState.IN_PROGRESS
|
|
90
|
+
>>> get_universal_state("closed", [])
|
|
91
|
+
TicketState.CLOSED
|
|
92
|
+
"""
|
|
93
|
+
# Closed issues are always CLOSED state
|
|
94
|
+
if github_state == "closed":
|
|
95
|
+
return TicketState.CLOSED
|
|
96
|
+
|
|
97
|
+
# Normalize labels for comparison
|
|
98
|
+
label_names = [label.lower() for label in labels]
|
|
99
|
+
|
|
100
|
+
# Check for extended state labels
|
|
101
|
+
for state, label_name in GitHubStateMapping.STATE_LABELS.items():
|
|
102
|
+
if label_name.lower() in label_names:
|
|
103
|
+
return state
|
|
104
|
+
|
|
105
|
+
# Default to OPEN if no state label found
|
|
106
|
+
return TicketState.OPEN
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def extract_state_from_issue(issue: dict[str, Any]) -> TicketState:
|
|
110
|
+
"""Extract ticket state from GitHub issue data.
|
|
111
|
+
|
|
112
|
+
Handles multiple GitHub API response formats:
|
|
113
|
+
- REST API v3: labels as array of objects
|
|
114
|
+
- GraphQL API v4: labels.nodes as array
|
|
115
|
+
- Legacy formats: labels as array of strings
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
----
|
|
119
|
+
issue: GitHub issue data from REST or GraphQL API
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
-------
|
|
123
|
+
Universal ticket state
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
-------
|
|
127
|
+
>>> issue = {"state": "open", "labels": [{"name": "ready"}]}
|
|
128
|
+
>>> extract_state_from_issue(issue)
|
|
129
|
+
TicketState.READY
|
|
130
|
+
"""
|
|
131
|
+
# Extract labels from various formats
|
|
132
|
+
labels = []
|
|
133
|
+
if "labels" in issue:
|
|
134
|
+
if isinstance(issue["labels"], list):
|
|
135
|
+
# REST API format: array of objects or strings
|
|
136
|
+
labels = [
|
|
137
|
+
label.get("name", "") if isinstance(label, dict) else str(label)
|
|
138
|
+
for label in issue["labels"]
|
|
139
|
+
]
|
|
140
|
+
elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
|
|
141
|
+
# GraphQL format: labels.nodes array
|
|
142
|
+
labels = [label["name"] for label in issue["labels"]["nodes"]]
|
|
143
|
+
|
|
144
|
+
return get_universal_state(issue["state"], labels)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_priority_from_labels(
|
|
148
|
+
labels: list[str],
|
|
149
|
+
custom_priority_scheme: dict[str, list[str]] | None = None,
|
|
150
|
+
) -> Priority:
|
|
151
|
+
"""Extract priority from GitHub issue labels.
|
|
152
|
+
|
|
153
|
+
Priority is inferred from labels since GitHub has no native priority field.
|
|
154
|
+
Supports custom priority label schemes for team-specific conventions.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
----
|
|
158
|
+
labels: List of label names
|
|
159
|
+
custom_priority_scheme: Optional custom mapping of priority -> label patterns
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
-------
|
|
163
|
+
Priority enum value (defaults to MEDIUM if not found)
|
|
164
|
+
|
|
165
|
+
Performance:
|
|
166
|
+
-----------
|
|
167
|
+
Time Complexity: O(n*m) where n=labels, m=priority patterns
|
|
168
|
+
Expected: ~20 labels * ~12 priority patterns = 240 comparisons worst case
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
-------
|
|
172
|
+
>>> get_priority_from_labels(["P0", "bug"])
|
|
173
|
+
Priority.CRITICAL
|
|
174
|
+
>>> get_priority_from_labels(["enhancement"])
|
|
175
|
+
Priority.MEDIUM # default
|
|
176
|
+
"""
|
|
177
|
+
label_names = [label.lower() for label in labels]
|
|
178
|
+
|
|
179
|
+
# Check custom priority scheme first
|
|
180
|
+
if custom_priority_scheme:
|
|
181
|
+
for priority_str, label_patterns in custom_priority_scheme.items():
|
|
182
|
+
for pattern in label_patterns:
|
|
183
|
+
if any(pattern.lower() in label for label in label_names):
|
|
184
|
+
return Priority(priority_str)
|
|
185
|
+
|
|
186
|
+
# Check default priority labels
|
|
187
|
+
for priority, priority_labels in GitHubStateMapping.PRIORITY_LABELS.items():
|
|
188
|
+
for priority_label in priority_labels:
|
|
189
|
+
if priority_label.lower() in label_names:
|
|
190
|
+
return priority
|
|
191
|
+
|
|
192
|
+
return Priority.MEDIUM
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_priority_label(
|
|
196
|
+
priority: Priority,
|
|
197
|
+
custom_priority_scheme: dict[str, list[str]] | None = None,
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Get label name for a priority level.
|
|
200
|
+
|
|
201
|
+
Returns the first matching label from custom scheme or default labels.
|
|
202
|
+
Falls back to P0/P1/P2/P3 notation if no match found.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
----
|
|
206
|
+
priority: Universal priority enum
|
|
207
|
+
custom_priority_scheme: Optional custom priority label mapping
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
-------
|
|
211
|
+
Label name to apply to issue
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
-------
|
|
215
|
+
>>> get_priority_label(Priority.CRITICAL)
|
|
216
|
+
'P0'
|
|
217
|
+
>>> get_priority_label(Priority.HIGH, {"high": ["urgent", "high-priority"]})
|
|
218
|
+
'urgent'
|
|
219
|
+
"""
|
|
220
|
+
# Check custom scheme first
|
|
221
|
+
if custom_priority_scheme:
|
|
222
|
+
labels = custom_priority_scheme.get(priority.value, [])
|
|
223
|
+
if labels:
|
|
224
|
+
return labels[0]
|
|
225
|
+
|
|
226
|
+
# Use default labels
|
|
227
|
+
labels = GitHubStateMapping.PRIORITY_LABELS.get(priority, [])
|
|
228
|
+
if labels:
|
|
229
|
+
return labels[0]
|
|
230
|
+
|
|
231
|
+
# Fallback to P0-P3 notation
|
|
232
|
+
priority_index = list(Priority).index(priority)
|
|
233
|
+
return f"P{priority_index}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_state_label(state: TicketState) -> str | None:
|
|
237
|
+
"""Get the label name for extended workflow states.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
----
|
|
241
|
+
state: Universal ticket state
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
-------
|
|
245
|
+
Label name if state requires a label, None for native GitHub states
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
-------
|
|
249
|
+
>>> get_state_label(TicketState.IN_PROGRESS)
|
|
250
|
+
'in-progress'
|
|
251
|
+
>>> get_state_label(TicketState.OPEN)
|
|
252
|
+
None # Native GitHub state, no label needed
|
|
253
|
+
"""
|
|
254
|
+
return GitHubStateMapping.STATE_LABELS.get(state)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_github_state(state: TicketState) -> str:
|
|
258
|
+
"""Map universal state to GitHub native state.
|
|
259
|
+
|
|
260
|
+
Only two valid values: 'open' or 'closed'.
|
|
261
|
+
Extended states map to 'open' with additional labels.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
----
|
|
265
|
+
state: Universal ticket state
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
-------
|
|
269
|
+
GitHub state string ('open' or 'closed')
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
-------
|
|
273
|
+
>>> get_github_state(TicketState.IN_PROGRESS)
|
|
274
|
+
'open'
|
|
275
|
+
>>> get_github_state(TicketState.CLOSED)
|
|
276
|
+
'closed'
|
|
277
|
+
"""
|
|
278
|
+
if state in (TicketState.DONE, TicketState.CLOSED):
|
|
279
|
+
return GitHubStateMapping.CLOSED
|
|
280
|
+
return GitHubStateMapping.OPEN
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# =============================================================================
|
|
284
|
+
# GitHub Projects V2 Type Definitions
|
|
285
|
+
# =============================================================================
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class ProjectV2Owner(TypedDict, total=False):
|
|
289
|
+
"""GitHub ProjectV2 owner (Organization or User).
|
|
290
|
+
|
|
291
|
+
Attributes:
|
|
292
|
+
__typename: Type discriminator ("Organization" or "User")
|
|
293
|
+
login: Owner login name
|
|
294
|
+
id: Owner node ID
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
__typename: str
|
|
298
|
+
login: str
|
|
299
|
+
id: str
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ProjectV2PageInfo(TypedDict, total=False):
|
|
303
|
+
"""GraphQL pagination info for ProjectV2 queries.
|
|
304
|
+
|
|
305
|
+
Attributes:
|
|
306
|
+
hasNextPage: Whether more results exist
|
|
307
|
+
endCursor: Cursor for next page
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
hasNextPage: bool
|
|
311
|
+
endCursor: str | None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class ProjectV2ItemsConnection(TypedDict, total=False):
|
|
315
|
+
"""ProjectV2 items connection.
|
|
316
|
+
|
|
317
|
+
Attributes:
|
|
318
|
+
totalCount: Total number of items in project
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
totalCount: int
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ProjectV2Node(TypedDict, total=False):
|
|
325
|
+
"""GitHub ProjectV2 GraphQL node.
|
|
326
|
+
|
|
327
|
+
Represents a single GitHub Projects V2 project from the GraphQL API.
|
|
328
|
+
This type matches the structure returned by PROJECT_V2_FRAGMENT.
|
|
329
|
+
|
|
330
|
+
Design Decision: Total vs Required Fields
|
|
331
|
+
-----------------------------------------
|
|
332
|
+
Using total=False allows optional fields to be omitted, matching the
|
|
333
|
+
GraphQL API where many fields are nullable or may not be queried.
|
|
334
|
+
|
|
335
|
+
Required fields (id, number, title) are still enforced at the Pydantic
|
|
336
|
+
model level in map_github_projectv2_to_project().
|
|
337
|
+
|
|
338
|
+
Attributes:
|
|
339
|
+
id: GitHub node ID (e.g., "PVT_kwDOABcdefgh")
|
|
340
|
+
number: Project number (e.g., 5)
|
|
341
|
+
title: Project title
|
|
342
|
+
shortDescription: Brief description (max 256 chars)
|
|
343
|
+
readme: Markdown readme content
|
|
344
|
+
public: Whether project is publicly visible
|
|
345
|
+
closed: Whether project is closed
|
|
346
|
+
url: Direct URL to project
|
|
347
|
+
createdAt: ISO timestamp of creation
|
|
348
|
+
updatedAt: ISO timestamp of last update
|
|
349
|
+
closedAt: ISO timestamp of closure (if closed)
|
|
350
|
+
owner: Owner (Organization or User)
|
|
351
|
+
items: Items connection with totalCount
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
id: str
|
|
355
|
+
number: int
|
|
356
|
+
title: str
|
|
357
|
+
shortDescription: str | None
|
|
358
|
+
readme: str | None
|
|
359
|
+
public: bool
|
|
360
|
+
closed: bool
|
|
361
|
+
url: str
|
|
362
|
+
createdAt: str
|
|
363
|
+
updatedAt: str
|
|
364
|
+
closedAt: str | None
|
|
365
|
+
owner: ProjectV2Owner
|
|
366
|
+
items: ProjectV2ItemsConnection | None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class ProjectV2Response(TypedDict, total=False):
|
|
370
|
+
"""Response from GET_PROJECT_QUERY or GET_PROJECT_BY_ID_QUERY.
|
|
371
|
+
|
|
372
|
+
Single project query response wrapping the project node.
|
|
373
|
+
|
|
374
|
+
Attributes:
|
|
375
|
+
organization: Organization containing projectV2 field
|
|
376
|
+
node: Direct node lookup result
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
organization: dict[str, ProjectV2Node | None]
|
|
380
|
+
node: ProjectV2Node | None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class ProjectV2Connection(TypedDict, total=False):
|
|
384
|
+
"""Connection of ProjectV2 nodes with pagination.
|
|
385
|
+
|
|
386
|
+
Attributes:
|
|
387
|
+
totalCount: Total number of projects
|
|
388
|
+
pageInfo: Pagination information
|
|
389
|
+
nodes: List of project nodes
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
totalCount: int
|
|
393
|
+
pageInfo: ProjectV2PageInfo
|
|
394
|
+
nodes: list[ProjectV2Node]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ProjectListResponse(TypedDict, total=False):
|
|
398
|
+
"""Response from LIST_PROJECTS_QUERY.
|
|
399
|
+
|
|
400
|
+
Attributes:
|
|
401
|
+
organization: Organization containing projectsV2 connection
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
organization: dict[str, ProjectV2Connection]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class ProjectItemContent(TypedDict, total=False):
|
|
408
|
+
"""Content of a project item (Issue, PR, or DraftIssue).
|
|
409
|
+
|
|
410
|
+
Attributes:
|
|
411
|
+
__typename: Content type discriminator
|
|
412
|
+
id: Content node ID
|
|
413
|
+
number: Issue/PR number (not present for DraftIssue)
|
|
414
|
+
title: Content title
|
|
415
|
+
state: Content state (OPEN/CLOSED for issues, etc.)
|
|
416
|
+
labels: Labels connection (issues only)
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
__typename: str
|
|
420
|
+
id: str
|
|
421
|
+
number: int | None
|
|
422
|
+
title: str
|
|
423
|
+
state: str | None
|
|
424
|
+
labels: dict[str, list[dict[str, str]]] | None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class ProjectItemNode(TypedDict, total=False):
|
|
428
|
+
"""Single project item node.
|
|
429
|
+
|
|
430
|
+
Attributes:
|
|
431
|
+
id: Project item ID (not the same as content ID)
|
|
432
|
+
content: The actual content (Issue, PR, or DraftIssue)
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
id: str
|
|
436
|
+
content: ProjectItemContent
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class ProjectItemsConnection(TypedDict, total=False):
|
|
440
|
+
"""Connection of project items with pagination.
|
|
441
|
+
|
|
442
|
+
Attributes:
|
|
443
|
+
totalCount: Total items in project
|
|
444
|
+
pageInfo: Pagination info
|
|
445
|
+
nodes: List of project item nodes
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
totalCount: int
|
|
449
|
+
pageInfo: ProjectV2PageInfo
|
|
450
|
+
nodes: list[ProjectItemNode]
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class ProjectItemsResponse(TypedDict, total=False):
|
|
454
|
+
"""Response from PROJECT_ITEMS_QUERY.
|
|
455
|
+
|
|
456
|
+
Attributes:
|
|
457
|
+
node: ProjectV2 node containing items connection
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
node: dict[str, ProjectItemsConnection]
|
mcp_ticketer/adapters/hybrid.py
CHANGED
|
@@ -8,7 +8,7 @@ import builtins
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
from ..core.adapter import BaseAdapter
|
|
14
14
|
from ..core.models import Comment, Epic, SearchQuery, Task, TicketState
|
|
@@ -73,6 +73,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
73
73
|
|
|
74
74
|
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
75
75
|
"""Get state mapping from primary adapter."""
|
|
76
|
+
# Type narrowing: primary_adapter_name is validated in __init__
|
|
77
|
+
if self.primary_adapter_name is None:
|
|
78
|
+
raise ValueError("Primary adapter name is not set")
|
|
76
79
|
primary = self.adapters[self.primary_adapter_name]
|
|
77
80
|
return primary._get_state_mapping()
|
|
78
81
|
|
|
@@ -129,7 +132,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
129
132
|
|
|
130
133
|
def _get_adapter_ticket_id(
|
|
131
134
|
self, universal_id: str, adapter_name: str
|
|
132
|
-
) ->
|
|
135
|
+
) -> str | None:
|
|
133
136
|
"""Get adapter-specific ticket ID from universal ID.
|
|
134
137
|
|
|
135
138
|
Args:
|
|
@@ -153,7 +156,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
153
156
|
|
|
154
157
|
return f"hybrid-{uuid.uuid4().hex[:12]}"
|
|
155
158
|
|
|
156
|
-
async def create(self, ticket:
|
|
159
|
+
async def create(self, ticket: Task | Epic) -> Task | Epic:
|
|
157
160
|
"""Create ticket in all configured adapters.
|
|
158
161
|
|
|
159
162
|
Args:
|
|
@@ -163,8 +166,11 @@ class HybridAdapter(BaseAdapter):
|
|
|
163
166
|
Created ticket with universal ID
|
|
164
167
|
|
|
165
168
|
"""
|
|
169
|
+
if self.primary_adapter_name is None:
|
|
170
|
+
raise ValueError("Primary adapter name is not set")
|
|
171
|
+
|
|
166
172
|
universal_id = self._generate_universal_id()
|
|
167
|
-
results = []
|
|
173
|
+
results: list[tuple[str, Task | Epic]] = []
|
|
168
174
|
|
|
169
175
|
# Create in primary adapter first
|
|
170
176
|
primary = self.adapters[self.primary_adapter_name]
|
|
@@ -208,7 +214,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
208
214
|
return primary_ticket
|
|
209
215
|
|
|
210
216
|
def _add_cross_references(
|
|
211
|
-
self, ticket:
|
|
217
|
+
self, ticket: Task | Epic, results: list[tuple[str, Task | Epic]]
|
|
212
218
|
) -> None:
|
|
213
219
|
"""Add cross-references to ticket description.
|
|
214
220
|
|
|
@@ -226,7 +232,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
226
232
|
else:
|
|
227
233
|
ticket.description = cross_refs.strip()
|
|
228
234
|
|
|
229
|
-
async def read(self, ticket_id: str) ->
|
|
235
|
+
async def read(self, ticket_id: str) -> Task | Epic | None:
|
|
230
236
|
"""Read ticket from primary adapter.
|
|
231
237
|
|
|
232
238
|
Args:
|
|
@@ -236,6 +242,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
236
242
|
Ticket if found, None otherwise
|
|
237
243
|
|
|
238
244
|
"""
|
|
245
|
+
if self.primary_adapter_name is None:
|
|
246
|
+
raise ValueError("Primary adapter name is not set")
|
|
247
|
+
|
|
239
248
|
# Check if this is a universal ID
|
|
240
249
|
if ticket_id.startswith("hybrid-"):
|
|
241
250
|
# Get primary adapter ticket ID
|
|
@@ -255,7 +264,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
255
264
|
|
|
256
265
|
async def update(
|
|
257
266
|
self, ticket_id: str, updates: dict[str, Any]
|
|
258
|
-
) ->
|
|
267
|
+
) -> Task | Epic | None:
|
|
259
268
|
"""Update ticket across all adapters.
|
|
260
269
|
|
|
261
270
|
Args:
|
|
@@ -266,7 +275,10 @@ class HybridAdapter(BaseAdapter):
|
|
|
266
275
|
Updated ticket from primary adapter
|
|
267
276
|
|
|
268
277
|
"""
|
|
269
|
-
|
|
278
|
+
if self.primary_adapter_name is None:
|
|
279
|
+
raise ValueError("Primary adapter name is not set")
|
|
280
|
+
|
|
281
|
+
universal_id: str | None = ticket_id
|
|
270
282
|
if not ticket_id.startswith("hybrid-"):
|
|
271
283
|
# Try to find universal ID by searching mapping
|
|
272
284
|
universal_id = self._find_universal_id(ticket_id)
|
|
@@ -279,6 +291,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
279
291
|
# Update in all adapters
|
|
280
292
|
results = []
|
|
281
293
|
for adapter_name, adapter in self.adapters.items():
|
|
294
|
+
if universal_id is None:
|
|
295
|
+
logger.warning(f"No universal ID available for ticket: {ticket_id}")
|
|
296
|
+
continue
|
|
282
297
|
adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
|
|
283
298
|
if not adapter_ticket_id:
|
|
284
299
|
logger.warning(f"No ticket ID for adapter {adapter_name}")
|
|
@@ -300,7 +315,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
300
315
|
|
|
301
316
|
return None
|
|
302
317
|
|
|
303
|
-
def _find_universal_id(self, adapter_ticket_id: str) ->
|
|
318
|
+
def _find_universal_id(self, adapter_ticket_id: str) -> str | None:
|
|
304
319
|
"""Find universal ID for an adapter-specific ticket ID.
|
|
305
320
|
|
|
306
321
|
Args:
|
|
@@ -325,7 +340,10 @@ class HybridAdapter(BaseAdapter):
|
|
|
325
340
|
True if deleted from at least one adapter
|
|
326
341
|
|
|
327
342
|
"""
|
|
328
|
-
|
|
343
|
+
if self.primary_adapter_name is None:
|
|
344
|
+
raise ValueError("Primary adapter name is not set")
|
|
345
|
+
|
|
346
|
+
universal_id: str | None = ticket_id
|
|
329
347
|
if not ticket_id.startswith("hybrid-"):
|
|
330
348
|
universal_id = self._find_universal_id(ticket_id)
|
|
331
349
|
if not universal_id:
|
|
@@ -336,6 +354,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
336
354
|
# Delete from all adapters
|
|
337
355
|
success_count = 0
|
|
338
356
|
for adapter_name, adapter in self.adapters.items():
|
|
357
|
+
if universal_id is None:
|
|
358
|
+
logger.warning(f"No universal ID available for ticket: {ticket_id}")
|
|
359
|
+
continue
|
|
339
360
|
adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
|
|
340
361
|
if not adapter_ticket_id:
|
|
341
362
|
continue
|
|
@@ -359,8 +380,8 @@ class HybridAdapter(BaseAdapter):
|
|
|
359
380
|
return success_count > 0
|
|
360
381
|
|
|
361
382
|
async def list(
|
|
362
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
363
|
-
) -> list[
|
|
383
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
384
|
+
) -> list[Task | Epic]:
|
|
364
385
|
"""List tickets from primary adapter.
|
|
365
386
|
|
|
366
387
|
Args:
|
|
@@ -372,10 +393,12 @@ class HybridAdapter(BaseAdapter):
|
|
|
372
393
|
List of tickets from primary adapter
|
|
373
394
|
|
|
374
395
|
"""
|
|
396
|
+
if self.primary_adapter_name is None:
|
|
397
|
+
raise ValueError("Primary adapter name is not set")
|
|
375
398
|
primary = self.adapters[self.primary_adapter_name]
|
|
376
399
|
return await primary.list(limit, offset, filters)
|
|
377
400
|
|
|
378
|
-
async def search(self, query: SearchQuery) -> builtins.list[
|
|
401
|
+
async def search(self, query: SearchQuery) -> builtins.list[Task | Epic]:
|
|
379
402
|
"""Search tickets in primary adapter.
|
|
380
403
|
|
|
381
404
|
Args:
|
|
@@ -385,12 +408,14 @@ class HybridAdapter(BaseAdapter):
|
|
|
385
408
|
List of tickets matching search criteria
|
|
386
409
|
|
|
387
410
|
"""
|
|
411
|
+
if self.primary_adapter_name is None:
|
|
412
|
+
raise ValueError("Primary adapter name is not set")
|
|
388
413
|
primary = self.adapters[self.primary_adapter_name]
|
|
389
414
|
return await primary.search(query)
|
|
390
415
|
|
|
391
416
|
async def transition_state(
|
|
392
417
|
self, ticket_id: str, target_state: TicketState
|
|
393
|
-
) ->
|
|
418
|
+
) -> Task | Epic | None:
|
|
394
419
|
"""Transition ticket state across all adapters.
|
|
395
420
|
|
|
396
421
|
Args:
|
|
@@ -401,7 +426,10 @@ class HybridAdapter(BaseAdapter):
|
|
|
401
426
|
Updated ticket from primary adapter
|
|
402
427
|
|
|
403
428
|
"""
|
|
404
|
-
|
|
429
|
+
if self.primary_adapter_name is None:
|
|
430
|
+
raise ValueError("Primary adapter name is not set")
|
|
431
|
+
|
|
432
|
+
universal_id: str | None = ticket_id
|
|
405
433
|
if not ticket_id.startswith("hybrid-"):
|
|
406
434
|
universal_id = self._find_universal_id(ticket_id)
|
|
407
435
|
if not universal_id:
|
|
@@ -412,6 +440,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
412
440
|
# Transition in all adapters
|
|
413
441
|
results = []
|
|
414
442
|
for adapter_name, adapter in self.adapters.items():
|
|
443
|
+
if universal_id is None:
|
|
444
|
+
logger.warning(f"No universal ID available for ticket: {ticket_id}")
|
|
445
|
+
continue
|
|
415
446
|
adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
|
|
416
447
|
if not adapter_ticket_id:
|
|
417
448
|
continue
|
|
@@ -446,7 +477,10 @@ class HybridAdapter(BaseAdapter):
|
|
|
446
477
|
Created comment from primary adapter
|
|
447
478
|
|
|
448
479
|
"""
|
|
449
|
-
|
|
480
|
+
if self.primary_adapter_name is None:
|
|
481
|
+
raise ValueError("Primary adapter name is not set")
|
|
482
|
+
|
|
483
|
+
universal_id: str | None = comment.ticket_id
|
|
450
484
|
if not comment.ticket_id.startswith("hybrid-"):
|
|
451
485
|
universal_id = self._find_universal_id(comment.ticket_id)
|
|
452
486
|
if not universal_id:
|
|
@@ -457,6 +491,11 @@ class HybridAdapter(BaseAdapter):
|
|
|
457
491
|
# Add comment to all adapters
|
|
458
492
|
results = []
|
|
459
493
|
for adapter_name, adapter in self.adapters.items():
|
|
494
|
+
if universal_id is None:
|
|
495
|
+
logger.warning(
|
|
496
|
+
f"No universal ID available for ticket: {comment.ticket_id}"
|
|
497
|
+
)
|
|
498
|
+
continue
|
|
460
499
|
adapter_ticket_id = self._get_adapter_ticket_id(universal_id, adapter_name)
|
|
461
500
|
if not adapter_ticket_id:
|
|
462
501
|
continue
|
|
@@ -501,6 +540,9 @@ class HybridAdapter(BaseAdapter):
|
|
|
501
540
|
List of comments from primary adapter
|
|
502
541
|
|
|
503
542
|
"""
|
|
543
|
+
if self.primary_adapter_name is None:
|
|
544
|
+
raise ValueError("Primary adapter name is not set")
|
|
545
|
+
|
|
504
546
|
if ticket_id.startswith("hybrid-"):
|
|
505
547
|
# Get primary adapter ticket ID
|
|
506
548
|
primary_id = self._get_adapter_ticket_id(
|