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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -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 +47 -5
- 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/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- 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 +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.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 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {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
|
+
}
|