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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1284
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {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]
@@ -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, Optional, Union
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
- ) -> Optional[str]:
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: Union[Task, Epic]) -> Union[Task, Epic]:
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: Union[Task, Epic], results: list[tuple[str, Union[Task, Epic]]]
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) -> Optional[Union[Task, Epic]]:
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
- ) -> Optional[Union[Task, Epic]]:
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
- universal_id = ticket_id
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) -> Optional[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
- universal_id = ticket_id
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: Optional[dict[str, Any]] = None
363
- ) -> list[Union[Task, Epic]]:
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[Union[Task, Epic]]:
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
- ) -> Optional[Union[Task, Epic]]:
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
- universal_id = ticket_id
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
- universal_id = comment.ticket_id
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(