mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,797 @@
1
+ """Data transformation between GitHub and universal models.
2
+
3
+ This module contains bidirectional mappers:
4
+ - GitHub → Universal models (read operations)
5
+ - Universal → GitHub input (write operations)
6
+
7
+ Design Pattern: Pure Transformation Functions
8
+ ---------------------------------------------
9
+ All mappers are pure functions with no side effects:
10
+ - Input: GitHub API data or universal models
11
+ - Output: Universal models or GitHub API input
12
+ - No API calls, no state mutations
13
+ - Fully testable in isolation
14
+
15
+ This separation enables:
16
+ 1. Easy unit testing without mocking HTTP clients
17
+ 2. Reusable transformations across different operations
18
+ 3. Clear data flow and debugging
19
+ 4. Token usage optimization through compact formats
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from datetime import date, datetime
25
+ from typing import Any
26
+
27
+ from ...core.models import (
28
+ Comment,
29
+ Epic,
30
+ Milestone,
31
+ Project,
32
+ ProjectScope,
33
+ ProjectState,
34
+ ProjectVisibility,
35
+ Task,
36
+ TicketState,
37
+ )
38
+ from .types import extract_state_from_issue, get_priority_from_labels
39
+
40
+
41
+ def map_github_issue_to_task(
42
+ issue: dict[str, Any],
43
+ custom_priority_scheme: dict[str, list[str]] | None = None,
44
+ ) -> Task:
45
+ """Convert GitHub issue to universal Task model.
46
+
47
+ Handles multiple GitHub API response formats:
48
+ - REST API v3: Traditional JSON structure
49
+ - GraphQL API v4: Nested nodes structure
50
+
51
+ Args:
52
+ ----
53
+ issue: GitHub issue data from REST or GraphQL API
54
+ custom_priority_scheme: Optional custom priority label mapping
55
+
56
+ Returns:
57
+ -------
58
+ Universal Task model
59
+
60
+ Performance:
61
+ -----------
62
+ Time Complexity: O(n) where n = number of labels
63
+ Expected: ~20 labels, ~100μs transformation time
64
+
65
+ Example:
66
+ -------
67
+ issue = {"number": 123, "title": "Bug fix", "state": "open", ...}
68
+ task = map_github_issue_to_task(issue)
69
+ assert task.id == "123"
70
+ assert task.state == TicketState.OPEN
71
+ """
72
+ # Extract labels (handle different formats)
73
+ labels = []
74
+ if "labels" in issue:
75
+ if isinstance(issue["labels"], list):
76
+ # REST API format: array of objects or strings
77
+ labels = [
78
+ label.get("name", "") if isinstance(label, dict) else str(label)
79
+ for label in issue["labels"]
80
+ ]
81
+ elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
82
+ # GraphQL format: labels.nodes array
83
+ labels = [label["name"] for label in issue["labels"]["nodes"]]
84
+
85
+ # Extract state using helper
86
+ state = extract_state_from_issue(issue)
87
+
88
+ # Extract priority from labels
89
+ priority = get_priority_from_labels(labels, custom_priority_scheme)
90
+
91
+ # Extract assignee (handle different formats)
92
+ assignee = None
93
+ if "assignees" in issue:
94
+ if isinstance(issue["assignees"], list) and issue["assignees"]:
95
+ assignee = issue["assignees"][0].get("login")
96
+ elif isinstance(issue["assignees"], dict) and "nodes" in issue["assignees"]:
97
+ nodes = issue["assignees"]["nodes"]
98
+ if nodes:
99
+ assignee = nodes[0].get("login")
100
+ elif "assignee" in issue and issue["assignee"]:
101
+ assignee = issue["assignee"].get("login")
102
+
103
+ # Extract parent epic (milestone)
104
+ parent_epic = None
105
+ if issue.get("milestone"):
106
+ parent_epic = str(issue["milestone"]["number"])
107
+
108
+ # Parse creation timestamp
109
+ created_at = None
110
+ if issue.get("created_at"):
111
+ created_at = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
112
+ elif issue.get("createdAt"):
113
+ created_at = datetime.fromisoformat(issue["createdAt"].replace("Z", "+00:00"))
114
+
115
+ # Parse update timestamp
116
+ updated_at = None
117
+ if issue.get("updated_at"):
118
+ updated_at = datetime.fromisoformat(issue["updated_at"].replace("Z", "+00:00"))
119
+ elif issue.get("updatedAt"):
120
+ updated_at = datetime.fromisoformat(issue["updatedAt"].replace("Z", "+00:00"))
121
+
122
+ # Build metadata
123
+ metadata = {
124
+ "github": {
125
+ "number": issue.get("number"),
126
+ "url": issue.get("url") or issue.get("html_url"),
127
+ "author": (
128
+ issue.get("user", {}).get("login")
129
+ if "user" in issue
130
+ else issue.get("author", {}).get("login")
131
+ ),
132
+ "labels": labels,
133
+ }
134
+ }
135
+
136
+ # Add Projects V2 info if available
137
+ if "projectCards" in issue and issue["projectCards"].get("nodes"):
138
+ metadata["github"]["projects"] = [
139
+ {
140
+ "name": card["project"]["name"],
141
+ "column": card["column"]["name"],
142
+ "url": card["project"]["url"],
143
+ }
144
+ for card in issue["projectCards"]["nodes"]
145
+ ]
146
+
147
+ return Task(
148
+ id=str(issue["number"]),
149
+ title=issue["title"],
150
+ description=issue.get("body") or issue.get("bodyText"),
151
+ state=state,
152
+ priority=priority,
153
+ tags=labels,
154
+ parent_epic=parent_epic,
155
+ assignee=assignee,
156
+ created_at=created_at,
157
+ updated_at=updated_at,
158
+ metadata=metadata,
159
+ )
160
+
161
+
162
+ def map_github_milestone_to_epic(milestone: dict[str, Any]) -> Epic:
163
+ """Convert GitHub milestone to universal Epic model.
164
+
165
+ Args:
166
+ ----
167
+ milestone: GitHub milestone data from API
168
+
169
+ Returns:
170
+ -------
171
+ Universal Epic model
172
+
173
+ Example:
174
+ -------
175
+ milestone = {"number": 1, "title": "v1.0", "state": "open", ...}
176
+ epic = map_github_milestone_to_epic(milestone)
177
+ assert epic.id == "1"
178
+ """
179
+ return Epic(
180
+ id=str(milestone["number"]),
181
+ title=milestone["title"],
182
+ description=milestone.get("description", ""),
183
+ state=(
184
+ TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
185
+ ),
186
+ created_at=datetime.fromisoformat(
187
+ milestone["created_at"].replace("Z", "+00:00")
188
+ ),
189
+ updated_at=datetime.fromisoformat(
190
+ milestone["updated_at"].replace("Z", "+00:00")
191
+ ),
192
+ metadata={
193
+ "github": {
194
+ "number": milestone["number"],
195
+ "url": milestone.get("html_url"),
196
+ "open_issues": milestone.get("open_issues", 0),
197
+ "closed_issues": milestone.get("closed_issues", 0),
198
+ }
199
+ },
200
+ )
201
+
202
+
203
+ def map_github_milestone_to_milestone(
204
+ gh_milestone: dict[str, Any],
205
+ repo: str,
206
+ labels: list[str] | None = None,
207
+ ) -> Milestone:
208
+ """Convert GitHub Milestone to universal Milestone model.
209
+
210
+ Args:
211
+ ----
212
+ gh_milestone: GitHub milestone data from API
213
+ repo: Repository name (used as project_id)
214
+ labels: Optional labels from local storage
215
+
216
+ Returns:
217
+ -------
218
+ Universal Milestone model
219
+
220
+ Example:
221
+ -------
222
+ milestone = map_github_milestone_to_milestone(
223
+ {"number": 1, "title": "Sprint 1", ...},
224
+ repo="my-repo"
225
+ )
226
+ """
227
+ # Parse target date
228
+ target_date = None
229
+ if gh_milestone.get("due_on"):
230
+ target_date = datetime.fromisoformat(
231
+ gh_milestone["due_on"].replace("Z", "+00:00")
232
+ ).date()
233
+
234
+ # Determine state
235
+ state = "closed" if gh_milestone["state"] == "closed" else "open"
236
+ if state == "open" and target_date:
237
+ if target_date < date.today():
238
+ state = "closed" # Past due
239
+ else:
240
+ state = "active"
241
+
242
+ # Calculate progress
243
+ total = gh_milestone.get("open_issues", 0) + gh_milestone.get("closed_issues", 0)
244
+ closed = gh_milestone.get("closed_issues", 0)
245
+ progress_pct = (closed / total * 100) if total > 0 else 0.0
246
+
247
+ return Milestone(
248
+ id=str(gh_milestone["number"]),
249
+ name=gh_milestone["title"],
250
+ description=gh_milestone.get("description", ""),
251
+ target_date=target_date,
252
+ state=state,
253
+ labels=labels or [],
254
+ total_issues=total,
255
+ closed_issues=closed,
256
+ progress_pct=progress_pct,
257
+ project_id=repo, # Repository name as project
258
+ created_at=(
259
+ datetime.fromisoformat(
260
+ gh_milestone.get("created_at", "").replace("Z", "+00:00")
261
+ )
262
+ if gh_milestone.get("created_at")
263
+ else None
264
+ ),
265
+ updated_at=(
266
+ datetime.fromisoformat(
267
+ gh_milestone.get("updated_at", "").replace("Z", "+00:00")
268
+ )
269
+ if gh_milestone.get("updated_at")
270
+ else None
271
+ ),
272
+ platform_data={
273
+ "github": {
274
+ "milestone_number": gh_milestone["number"],
275
+ "url": gh_milestone.get("html_url"),
276
+ "created_at": gh_milestone.get("created_at"),
277
+ "updated_at": gh_milestone.get("updated_at"),
278
+ }
279
+ },
280
+ )
281
+
282
+
283
+ def map_github_comment_to_comment(
284
+ comment_data: dict[str, Any],
285
+ ticket_id: str,
286
+ ) -> Comment:
287
+ """Convert GitHub comment to universal Comment model.
288
+
289
+ Args:
290
+ ----
291
+ comment_data: GitHub comment data from API
292
+ ticket_id: Associated issue number
293
+
294
+ Returns:
295
+ -------
296
+ Universal Comment model
297
+
298
+ Example:
299
+ -------
300
+ comment = map_github_comment_to_comment(
301
+ {"id": 123, "body": "Great work!", "author": {"login": "user"}},
302
+ ticket_id="456"
303
+ )
304
+ """
305
+ return Comment(
306
+ id=str(comment_data["id"]),
307
+ ticket_id=ticket_id,
308
+ content=comment_data["body"],
309
+ author=comment_data.get("author", {}).get("login"),
310
+ created_at=datetime.fromisoformat(
311
+ comment_data["createdAt"].replace("Z", "+00:00")
312
+ ),
313
+ )
314
+
315
+
316
+ def build_github_issue_input(
317
+ task: Task,
318
+ state_label: str | None = None,
319
+ priority_label: str | None = None,
320
+ ) -> dict[str, Any]:
321
+ """Build GitHub issue creation input from Task.
322
+
323
+ Args:
324
+ ----
325
+ task: Universal task model
326
+ state_label: Optional state label to add
327
+ priority_label: Optional priority label to add
328
+
329
+ Returns:
330
+ -------
331
+ GitHub API issue creation payload
332
+
333
+ Example:
334
+ -------
335
+ input_data = build_github_issue_input(
336
+ task=Task(title="Fix bug", description="..."),
337
+ state_label="in-progress",
338
+ priority_label="P0"
339
+ )
340
+ # input_data = {"title": "Fix bug", "body": "...", "labels": [...]}
341
+ """
342
+ # Start with basic fields
343
+ issue_data = {
344
+ "title": task.title,
345
+ "body": task.description or "",
346
+ }
347
+
348
+ # Build labels list
349
+ labels = list(task.tags) if task.tags else []
350
+ if state_label:
351
+ labels.append(state_label)
352
+ if priority_label:
353
+ labels.append(priority_label)
354
+
355
+ if labels:
356
+ issue_data["labels"] = labels
357
+
358
+ # Add assignee if specified
359
+ if task.assignee:
360
+ issue_data["assignees"] = [task.assignee]
361
+
362
+ # Add milestone if parent_epic is specified
363
+ if task.parent_epic:
364
+ try:
365
+ milestone_number = int(task.parent_epic)
366
+ issue_data["milestone"] = milestone_number
367
+ except ValueError:
368
+ # If parent_epic is not a number, caller should resolve it
369
+ pass
370
+
371
+ return issue_data
372
+
373
+
374
+ def build_github_issue_update_input(
375
+ updates: dict[str, Any],
376
+ state_label: str | None = None,
377
+ priority_label: str | None = None,
378
+ remove_state_labels: list[str] | None = None,
379
+ ) -> dict[str, Any]:
380
+ """Build GitHub issue update input from universal updates.
381
+
382
+ Args:
383
+ ----
384
+ updates: Universal update fields
385
+ state_label: Optional new state label to add
386
+ priority_label: Optional new priority label to add
387
+ remove_state_labels: Optional state labels to remove
388
+
389
+ Returns:
390
+ -------
391
+ GitHub API issue update payload
392
+
393
+ Example:
394
+ -------
395
+ update_data = build_github_issue_update_input(
396
+ updates={"title": "New title", "description": "New desc"},
397
+ state_label="ready"
398
+ )
399
+ """
400
+ issue_updates: dict[str, Any] = {}
401
+
402
+ # Map universal fields to GitHub fields
403
+ if "title" in updates:
404
+ issue_updates["title"] = updates["title"]
405
+
406
+ if "description" in updates:
407
+ issue_updates["body"] = updates["description"]
408
+
409
+ if "assignee" in updates:
410
+ assignee = updates["assignee"]
411
+ issue_updates["assignees"] = [assignee] if assignee else []
412
+
413
+ # Handle labels
414
+ labels = []
415
+ if "tags" in updates:
416
+ labels = list(updates["tags"])
417
+
418
+ # Add state/priority labels if provided
419
+ if state_label:
420
+ labels.append(state_label)
421
+ if priority_label:
422
+ labels.append(priority_label)
423
+
424
+ if labels:
425
+ issue_updates["labels"] = labels
426
+
427
+ # Handle milestone
428
+ if "parent_epic" in updates:
429
+ parent_epic = updates["parent_epic"]
430
+ if parent_epic:
431
+ try:
432
+ milestone_number = int(parent_epic)
433
+ issue_updates["milestone"] = milestone_number
434
+ except ValueError:
435
+ pass
436
+ else:
437
+ # Remove milestone
438
+ issue_updates["milestone"] = None
439
+
440
+ return issue_updates
441
+
442
+
443
+ def task_to_compact_format(task: Task) -> dict[str, Any]:
444
+ """Convert Task to compact format for token optimization.
445
+
446
+ Compact format includes only essential fields:
447
+ - id, title, state, priority, assignee
448
+
449
+ Full format would include:
450
+ - description, tags, children, dates, metadata
451
+
452
+ Token Savings:
453
+ -------------
454
+ Compact: ~120 tokens per task
455
+ Full: ~600 tokens per task
456
+ Savings: 80% reduction for large result sets
457
+
458
+ Args:
459
+ ----
460
+ task: Universal task model
461
+
462
+ Returns:
463
+ -------
464
+ Compact representation dict
465
+
466
+ Example:
467
+ -------
468
+ task = Task(id="123", title="Fix bug", state=TicketState.OPEN, ...)
469
+ compact = task_to_compact_format(task)
470
+ # compact = {"id": "123", "title": "Fix bug", "state": "open", ...}
471
+ """
472
+ return {
473
+ "id": task.id,
474
+ "title": task.title,
475
+ "state": (
476
+ task.state
477
+ if isinstance(task.state, str)
478
+ else (task.state.value if task.state else None)
479
+ ),
480
+ "priority": (
481
+ task.priority
482
+ if isinstance(task.priority, str)
483
+ else (task.priority.value if task.priority else None)
484
+ ),
485
+ "assignee": task.assignee,
486
+ }
487
+
488
+
489
+ def epic_to_compact_format(epic: Epic) -> dict[str, Any]:
490
+ """Convert Epic to compact format for token optimization.
491
+
492
+ Args:
493
+ ----
494
+ epic: Universal epic model
495
+
496
+ Returns:
497
+ -------
498
+ Compact representation dict
499
+ """
500
+ return {
501
+ "id": epic.id,
502
+ "title": epic.title,
503
+ "state": (
504
+ epic.state
505
+ if isinstance(epic.state, str)
506
+ else (epic.state.value if epic.state else None)
507
+ ),
508
+ }
509
+
510
+
511
+ # =============================================================================
512
+ # ProjectV2 Mappers (GitHub Projects V2 Support)
513
+ # =============================================================================
514
+
515
+
516
+ def map_github_projectv2_to_project(
517
+ data: dict[str, Any],
518
+ owner: str,
519
+ ) -> Project:
520
+ """Convert GitHub ProjectV2 GraphQL response to universal Project model.
521
+
522
+ Handles the mapping from GitHub's ProjectV2 API to our unified Project model,
523
+ including scope detection, state mapping, and metadata extraction.
524
+
525
+ Design Decision: Scope Detection
526
+ ---------------------------------
527
+ GitHub ProjectV2 can belong to either an Organization or User. We detect
528
+ the scope from the owner.__typename field in the GraphQL response.
529
+
530
+ Args:
531
+ ----
532
+ data: ProjectV2 node from GraphQL response containing project metadata
533
+ owner: Owner login (organization or user) for URL construction
534
+
535
+ Returns:
536
+ -------
537
+ Universal Project model with mapped fields
538
+
539
+ Performance:
540
+ -----------
541
+ Time Complexity: O(1) - Direct field mapping
542
+ Expected: <1ms transformation time
543
+
544
+ Error Handling:
545
+ --------------
546
+ - Missing optional fields default to None
547
+ - Required fields (id, number, title) must be present or raises KeyError
548
+ - Invalid date formats are caught and set to None
549
+
550
+ Example:
551
+ -------
552
+ >>> data = {
553
+ ... "id": "PVT_kwDOABcdefgh",
554
+ ... "number": 5,
555
+ ... "title": "Product Roadmap",
556
+ ... "shortDescription": "Q4 2025 roadmap",
557
+ ... "public": True,
558
+ ... "closed": False,
559
+ ... "url": "https://github.com/orgs/my-org/projects/5",
560
+ ... "owner": {"__typename": "Organization", "login": "my-org", "id": "ORG123"}
561
+ ... }
562
+ >>> project = map_github_projectv2_to_project(data, "my-org")
563
+ >>> assert project.platform == "github"
564
+ >>> assert project.scope == ProjectScope.ORGANIZATION
565
+ >>> assert project.state == ProjectState.ACTIVE
566
+ """
567
+ # Determine scope from owner type
568
+ owner_data = data.get("owner", {})
569
+ owner_type = owner_data.get("__typename", "Organization")
570
+ scope = (
571
+ ProjectScope.ORGANIZATION if owner_type == "Organization" else ProjectScope.USER
572
+ )
573
+
574
+ # Map closed boolean to ProjectState
575
+ # GitHub Projects V2 only has open/closed, map to our more granular states
576
+ closed = data.get("closed", False)
577
+ state = ProjectState.COMPLETED if closed else ProjectState.ACTIVE
578
+
579
+ # Check for closedAt timestamp to differentiate COMPLETED from ARCHIVED
580
+ if closed and data.get("closedAt"):
581
+ # If closed recently (within 30 days), mark as COMPLETED
582
+ # Otherwise, consider it ARCHIVED
583
+ try:
584
+ closed_at = datetime.fromisoformat(data["closedAt"].replace("Z", "+00:00"))
585
+ days_since_close = (datetime.now(closed_at.tzinfo) - closed_at).days
586
+ state = (
587
+ ProjectState.COMPLETED
588
+ if days_since_close < 30
589
+ else ProjectState.ARCHIVED
590
+ )
591
+ except (ValueError, TypeError):
592
+ state = ProjectState.COMPLETED
593
+
594
+ # Map public boolean to ProjectVisibility
595
+ public = data.get("public", False)
596
+ visibility = ProjectVisibility.PUBLIC if public else ProjectVisibility.PRIVATE
597
+
598
+ # Parse timestamps
599
+ created_at = None
600
+ if data.get("createdAt"):
601
+ try:
602
+ created_at = datetime.fromisoformat(
603
+ data["createdAt"].replace("Z", "+00:00")
604
+ )
605
+ except (ValueError, TypeError):
606
+ pass
607
+
608
+ updated_at = None
609
+ if data.get("updatedAt"):
610
+ try:
611
+ updated_at = datetime.fromisoformat(
612
+ data["updatedAt"].replace("Z", "+00:00")
613
+ )
614
+ except (ValueError, TypeError):
615
+ pass
616
+
617
+ completed_at = None
618
+ if data.get("closedAt"):
619
+ try:
620
+ completed_at = datetime.fromisoformat(
621
+ data["closedAt"].replace("Z", "+00:00")
622
+ )
623
+ except (ValueError, TypeError):
624
+ pass
625
+
626
+ # Extract description (prefer shortDescription, fall back to readme)
627
+ description = data.get("shortDescription")
628
+ if not description:
629
+ # If readme exists, use first line or truncate
630
+ readme = data.get("readme", "")
631
+ if readme:
632
+ # Take first line or first 200 chars
633
+ first_line = readme.split("\n")[0]
634
+ description = first_line[:200] if len(first_line) > 200 else first_line
635
+
636
+ # Get issue count from items.totalCount if available
637
+ issue_count = None
638
+ if "items" in data and data["items"]:
639
+ issue_count = data["items"].get("totalCount")
640
+
641
+ # Build Project model
642
+ return Project(
643
+ # Core identification
644
+ id=data["id"], # GitHub node ID (e.g., "PVT_kwDOABcdefgh")
645
+ platform="github",
646
+ platform_id=str(data["number"]), # Project number (e.g., "5")
647
+ scope=scope,
648
+ # Basic information
649
+ name=data["title"],
650
+ description=description,
651
+ state=state,
652
+ visibility=visibility,
653
+ # URLs and dates
654
+ url=data.get("url"),
655
+ created_at=created_at,
656
+ updated_at=updated_at,
657
+ completed_at=completed_at,
658
+ # Ownership
659
+ owner_id=owner_data.get("id"),
660
+ owner_name=owner_data.get("login"),
661
+ # Issue tracking
662
+ issue_count=issue_count,
663
+ # Platform-specific data
664
+ extra_data={
665
+ "github": {
666
+ "number": data["number"],
667
+ "owner_login": owner_data.get("login"),
668
+ "owner_type": owner_type,
669
+ "readme": data.get("readme"),
670
+ "closed": data.get("closed", False),
671
+ "public": data.get("public", False),
672
+ }
673
+ },
674
+ )
675
+
676
+
677
+ def calculate_project_statistics(
678
+ items_data: list[dict[str, Any]],
679
+ ) -> dict[str, int]:
680
+ """Calculate project statistics from GitHub ProjectV2 items.
681
+
682
+ Analyzes project items to compute state-based counts and progress metrics.
683
+ This function processes the raw GraphQL response from PROJECT_ITEMS_QUERY.
684
+
685
+ Design Decision: State Detection
686
+ ---------------------------------
687
+ GitHub issues have native states (open/closed) plus label-based extended
688
+ states (in-progress, blocked, etc.). We use the extract_state_from_issue
689
+ helper to determine the universal TicketState.
690
+
691
+ Priority Detection:
692
+ ------------------
693
+ Priority is inferred from labels using the get_priority_from_labels helper,
694
+ which checks for P0/P1/P2/P3 or critical/high/medium/low patterns.
695
+
696
+ Args:
697
+ ----
698
+ items_data: List of project item nodes from GraphQL response.
699
+ Each item contains {id, content: {Issue | PullRequest | DraftIssue}}
700
+
701
+ Returns:
702
+ -------
703
+ Dictionary with calculated statistics:
704
+ - total_issues: Total items (issues + PRs)
705
+ - total_issues_only: Issues only (excludes PRs and drafts)
706
+ - open_issues: Issues in OPEN state
707
+ - in_progress_issues: Issues in IN_PROGRESS state
708
+ - completed_issues: Issues in DONE or CLOSED state
709
+ - blocked_issues: Issues in BLOCKED state
710
+ - priority_counts: Dict mapping priority to count
711
+
712
+ Performance:
713
+ -----------
714
+ Time Complexity: O(n*m) where n=items, m=labels per item
715
+ Expected: ~100 items * ~20 labels = 2000 comparisons, <10ms
716
+
717
+ Error Handling:
718
+ --------------
719
+ - Filters out non-Issue items (PRs, draft issues)
720
+ - Handles missing labels gracefully (defaults to OPEN state)
721
+ - Returns zero counts if items_data is empty
722
+
723
+ Example:
724
+ -------
725
+ >>> items = [
726
+ ... {"content": {"__typename": "Issue", "state": "OPEN", "labels": {"nodes": []}}},
727
+ ... {"content": {"__typename": "Issue", "state": "OPEN", "labels": {"nodes": [{"name": "in-progress"}]}}},
728
+ ... {"content": {"__typename": "PullRequest", "state": "OPEN"}}
729
+ ... ]
730
+ >>> stats = calculate_project_statistics(items)
731
+ >>> assert stats["total_issues_only"] == 2
732
+ >>> assert stats["in_progress_issues"] == 1
733
+ """
734
+ # Initialize counters
735
+ total_issues = 0
736
+ total_issues_only = 0
737
+ open_issues = 0
738
+ in_progress_issues = 0
739
+ completed_issues = 0
740
+ blocked_issues = 0
741
+ priority_counts: dict[str, int] = {
742
+ "critical": 0,
743
+ "high": 0,
744
+ "medium": 0,
745
+ "low": 0,
746
+ }
747
+
748
+ for item in items_data:
749
+ content = item.get("content", {})
750
+ content_type = content.get("__typename")
751
+
752
+ # Count all items (issues + PRs)
753
+ total_issues += 1
754
+
755
+ # Only analyze Issues (skip PRs and draft issues)
756
+ if content_type != "Issue":
757
+ continue
758
+
759
+ total_issues_only += 1
760
+
761
+ # Extract labels for state and priority detection
762
+ labels = []
763
+ if "labels" in content and content["labels"]:
764
+ label_nodes = content["labels"].get("nodes", [])
765
+ labels = [label["name"] for label in label_nodes]
766
+
767
+ # Determine state using helper function
768
+ # This maps GitHub's open/closed + labels to our TicketState enum
769
+ issue_dict = {
770
+ "state": content.get("state", "").lower(),
771
+ "labels": [{"name": label} for label in labels],
772
+ }
773
+ state = extract_state_from_issue(issue_dict)
774
+
775
+ # Count by state
776
+ if state == TicketState.OPEN:
777
+ open_issues += 1
778
+ elif state == TicketState.IN_PROGRESS:
779
+ in_progress_issues += 1
780
+ elif state in (TicketState.DONE, TicketState.CLOSED):
781
+ completed_issues += 1
782
+ elif state == TicketState.BLOCKED:
783
+ blocked_issues += 1
784
+
785
+ # Count by priority
786
+ priority = get_priority_from_labels(labels)
787
+ priority_counts[priority.value] += 1
788
+
789
+ return {
790
+ "total_issues": total_issues,
791
+ "total_issues_only": total_issues_only,
792
+ "open_issues": open_issues,
793
+ "in_progress_issues": in_progress_issues,
794
+ "completed_issues": completed_issues,
795
+ "blocked_issues": blocked_issues,
796
+ "priority_counts": priority_counts,
797
+ }