mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__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/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import builtins
|
|
4
4
|
import re
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
|
|
@@ -141,11 +142,11 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
141
142
|
|
|
142
143
|
Args:
|
|
143
144
|
config: Configuration with:
|
|
144
|
-
- token: GitHub
|
|
145
|
+
- token: GitHub PAT (or GITHUB_TOKEN env var)
|
|
145
146
|
- owner: Repository owner (or GITHUB_OWNER env var)
|
|
146
147
|
- repo: Repository name (or GITHUB_REPO env var)
|
|
147
|
-
- api_url: Optional API URL for GitHub Enterprise
|
|
148
|
-
- use_projects_v2: Enable
|
|
148
|
+
- api_url: Optional API URL for GitHub Enterprise
|
|
149
|
+
- use_projects_v2: Enable Projects v2 (default: False)
|
|
149
150
|
- custom_priority_scheme: Custom priority label mapping
|
|
150
151
|
|
|
151
152
|
"""
|
|
@@ -157,11 +158,12 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
157
158
|
# Validate required configuration
|
|
158
159
|
missing_keys = validate_adapter_config("github", full_config)
|
|
159
160
|
if missing_keys:
|
|
161
|
+
missing = ", ".join(missing_keys)
|
|
160
162
|
raise ValueError(
|
|
161
|
-
f"GitHub adapter missing required configuration: {
|
|
163
|
+
f"GitHub adapter missing required configuration: {missing}"
|
|
162
164
|
)
|
|
163
165
|
|
|
164
|
-
# Get authentication token - support
|
|
166
|
+
# Get authentication token - support 'api_key' and 'token'
|
|
165
167
|
self.token = (
|
|
166
168
|
full_config.get("api_key")
|
|
167
169
|
or full_config.get("token")
|
|
@@ -198,8 +200,8 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
198
200
|
)
|
|
199
201
|
|
|
200
202
|
# Cache for labels and milestones
|
|
201
|
-
self._labels_cache:
|
|
202
|
-
self._milestones_cache:
|
|
203
|
+
self._labels_cache: list[dict[str, Any]] | None = None
|
|
204
|
+
self._milestones_cache: list[dict[str, Any]] | None = None
|
|
203
205
|
self._rate_limit: dict[str, Any] = {}
|
|
204
206
|
|
|
205
207
|
def validate_credentials(self) -> tuple[bool, str]:
|
|
@@ -212,17 +214,21 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
212
214
|
if not self.token:
|
|
213
215
|
return (
|
|
214
216
|
False,
|
|
215
|
-
"GITHUB_TOKEN is required
|
|
217
|
+
"GITHUB_TOKEN is required. Set it in .env.local or environment.",
|
|
216
218
|
)
|
|
217
219
|
if not self.owner:
|
|
218
220
|
return (
|
|
219
221
|
False,
|
|
220
|
-
"GitHub owner is required
|
|
222
|
+
"GitHub owner is required. Set GITHUB_OWNER in .env.local "
|
|
223
|
+
"or configure with 'mcp-ticketer init --adapter github "
|
|
224
|
+
"--github-owner <owner>'",
|
|
221
225
|
)
|
|
222
226
|
if not self.repo:
|
|
223
227
|
return (
|
|
224
228
|
False,
|
|
225
|
-
"GitHub repo is required
|
|
229
|
+
"GitHub repo is required. Set GITHUB_REPO in .env.local "
|
|
230
|
+
"or configure with 'mcp-ticketer init --adapter github "
|
|
231
|
+
"--github-repo <repo>'",
|
|
226
232
|
)
|
|
227
233
|
return True, ""
|
|
228
234
|
|
|
@@ -239,7 +245,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
239
245
|
TicketState.CLOSED: GitHubStateMapping.CLOSED,
|
|
240
246
|
}
|
|
241
247
|
|
|
242
|
-
def _get_state_label(self, state: TicketState) ->
|
|
248
|
+
def _get_state_label(self, state: TicketState) -> str | None:
|
|
243
249
|
"""Get the label name for extended states."""
|
|
244
250
|
return GitHubStateMapping.STATE_LABELS.get(state)
|
|
245
251
|
|
|
@@ -278,6 +284,39 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
278
284
|
else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
|
|
279
285
|
)
|
|
280
286
|
|
|
287
|
+
def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
|
|
288
|
+
"""Convert GitHub milestone to Epic model.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
milestone: GitHub milestone data
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Epic instance
|
|
295
|
+
|
|
296
|
+
"""
|
|
297
|
+
return Epic(
|
|
298
|
+
id=str(milestone["number"]),
|
|
299
|
+
title=milestone["title"],
|
|
300
|
+
description=milestone.get("description", ""),
|
|
301
|
+
state=(
|
|
302
|
+
TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
|
|
303
|
+
),
|
|
304
|
+
created_at=datetime.fromisoformat(
|
|
305
|
+
milestone["created_at"].replace("Z", "+00:00")
|
|
306
|
+
),
|
|
307
|
+
updated_at=datetime.fromisoformat(
|
|
308
|
+
milestone["updated_at"].replace("Z", "+00:00")
|
|
309
|
+
),
|
|
310
|
+
metadata={
|
|
311
|
+
"github": {
|
|
312
|
+
"number": milestone["number"],
|
|
313
|
+
"url": milestone.get("html_url"),
|
|
314
|
+
"open_issues": milestone.get("open_issues", 0),
|
|
315
|
+
"closed_issues": milestone.get("closed_issues", 0),
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
|
|
281
320
|
def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
|
|
282
321
|
"""Extract ticket state from GitHub issue data."""
|
|
283
322
|
# Check if closed
|
|
@@ -508,7 +547,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
508
547
|
|
|
509
548
|
return self._task_from_github_issue(created_issue)
|
|
510
549
|
|
|
511
|
-
async def read(self, ticket_id: str) ->
|
|
550
|
+
async def read(self, ticket_id: str) -> Task | None:
|
|
512
551
|
"""Read a GitHub issue by number."""
|
|
513
552
|
# Validate credentials before attempting operation
|
|
514
553
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -533,7 +572,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
533
572
|
except httpx.HTTPError:
|
|
534
573
|
return None
|
|
535
574
|
|
|
536
|
-
async def update(self, ticket_id: str, updates: dict[str, Any]) ->
|
|
575
|
+
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
|
|
537
576
|
"""Update a GitHub issue."""
|
|
538
577
|
# Validate credentials before attempting operation
|
|
539
578
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -685,11 +724,11 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
685
724
|
return False
|
|
686
725
|
|
|
687
726
|
async def list(
|
|
688
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
727
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
689
728
|
) -> list[Task]:
|
|
690
729
|
"""List GitHub issues with filters."""
|
|
691
730
|
# Build query parameters
|
|
692
|
-
params = {
|
|
731
|
+
params: dict[str, Any] = {
|
|
693
732
|
"per_page": min(limit, 100), # GitHub max is 100
|
|
694
733
|
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
695
734
|
}
|
|
@@ -837,7 +876,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
837
876
|
|
|
838
877
|
async def transition_state(
|
|
839
878
|
self, ticket_id: str, target_state: TicketState
|
|
840
|
-
) ->
|
|
879
|
+
) -> Task | None:
|
|
841
880
|
"""Transition GitHub issue to a new state."""
|
|
842
881
|
# Validate transition
|
|
843
882
|
if not await self.validate_transition(ticket_id, target_state):
|
|
@@ -850,8 +889,8 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
850
889
|
"""Add a comment to a GitHub issue."""
|
|
851
890
|
try:
|
|
852
891
|
issue_number = int(comment.ticket_id)
|
|
853
|
-
except ValueError:
|
|
854
|
-
raise ValueError(f"Invalid issue number: {comment.ticket_id}")
|
|
892
|
+
except ValueError as e:
|
|
893
|
+
raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
|
|
855
894
|
|
|
856
895
|
# Create comment
|
|
857
896
|
response = await self.client.post(
|
|
@@ -945,33 +984,9 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
945
984
|
response.raise_for_status()
|
|
946
985
|
|
|
947
986
|
created_milestone = response.json()
|
|
987
|
+
return self._milestone_to_epic(created_milestone)
|
|
948
988
|
|
|
949
|
-
|
|
950
|
-
id=str(created_milestone["number"]),
|
|
951
|
-
title=created_milestone["title"],
|
|
952
|
-
description=created_milestone["description"],
|
|
953
|
-
state=(
|
|
954
|
-
TicketState.OPEN
|
|
955
|
-
if created_milestone["state"] == "open"
|
|
956
|
-
else TicketState.CLOSED
|
|
957
|
-
),
|
|
958
|
-
created_at=datetime.fromisoformat(
|
|
959
|
-
created_milestone["created_at"].replace("Z", "+00:00")
|
|
960
|
-
),
|
|
961
|
-
updated_at=datetime.fromisoformat(
|
|
962
|
-
created_milestone["updated_at"].replace("Z", "+00:00")
|
|
963
|
-
),
|
|
964
|
-
metadata={
|
|
965
|
-
"github": {
|
|
966
|
-
"number": created_milestone["number"],
|
|
967
|
-
"url": created_milestone["html_url"],
|
|
968
|
-
"open_issues": created_milestone["open_issues"],
|
|
969
|
-
"closed_issues": created_milestone["closed_issues"],
|
|
970
|
-
}
|
|
971
|
-
},
|
|
972
|
-
)
|
|
973
|
-
|
|
974
|
-
async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
|
|
989
|
+
async def get_milestone(self, milestone_number: int) -> Epic | None:
|
|
975
990
|
"""Get a GitHub milestone as an Epic."""
|
|
976
991
|
try:
|
|
977
992
|
response = await self.client.get(
|
|
@@ -982,31 +997,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
982
997
|
response.raise_for_status()
|
|
983
998
|
|
|
984
999
|
milestone = response.json()
|
|
985
|
-
|
|
986
|
-
return Epic(
|
|
987
|
-
id=str(milestone["number"]),
|
|
988
|
-
title=milestone["title"],
|
|
989
|
-
description=milestone["description"],
|
|
990
|
-
state=(
|
|
991
|
-
TicketState.OPEN
|
|
992
|
-
if milestone["state"] == "open"
|
|
993
|
-
else TicketState.CLOSED
|
|
994
|
-
),
|
|
995
|
-
created_at=datetime.fromisoformat(
|
|
996
|
-
milestone["created_at"].replace("Z", "+00:00")
|
|
997
|
-
),
|
|
998
|
-
updated_at=datetime.fromisoformat(
|
|
999
|
-
milestone["updated_at"].replace("Z", "+00:00")
|
|
1000
|
-
),
|
|
1001
|
-
metadata={
|
|
1002
|
-
"github": {
|
|
1003
|
-
"number": milestone["number"],
|
|
1004
|
-
"url": milestone["html_url"],
|
|
1005
|
-
"open_issues": milestone["open_issues"],
|
|
1006
|
-
"closed_issues": milestone["closed_issues"],
|
|
1007
|
-
}
|
|
1008
|
-
},
|
|
1009
|
-
)
|
|
1000
|
+
return self._milestone_to_epic(milestone)
|
|
1010
1001
|
except httpx.HTTPError:
|
|
1011
1002
|
return None
|
|
1012
1003
|
|
|
@@ -1025,36 +1016,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1025
1016
|
)
|
|
1026
1017
|
response.raise_for_status()
|
|
1027
1018
|
|
|
1028
|
-
|
|
1029
|
-
for milestone in response.json():
|
|
1030
|
-
epics.append(
|
|
1031
|
-
Epic(
|
|
1032
|
-
id=str(milestone["number"]),
|
|
1033
|
-
title=milestone["title"],
|
|
1034
|
-
description=milestone["description"],
|
|
1035
|
-
state=(
|
|
1036
|
-
TicketState.OPEN
|
|
1037
|
-
if milestone["state"] == "open"
|
|
1038
|
-
else TicketState.CLOSED
|
|
1039
|
-
),
|
|
1040
|
-
created_at=datetime.fromisoformat(
|
|
1041
|
-
milestone["created_at"].replace("Z", "+00:00")
|
|
1042
|
-
),
|
|
1043
|
-
updated_at=datetime.fromisoformat(
|
|
1044
|
-
milestone["updated_at"].replace("Z", "+00:00")
|
|
1045
|
-
),
|
|
1046
|
-
metadata={
|
|
1047
|
-
"github": {
|
|
1048
|
-
"number": milestone["number"],
|
|
1049
|
-
"url": milestone["html_url"],
|
|
1050
|
-
"open_issues": milestone["open_issues"],
|
|
1051
|
-
"closed_issues": milestone["closed_issues"],
|
|
1052
|
-
}
|
|
1053
|
-
},
|
|
1054
|
-
)
|
|
1055
|
-
)
|
|
1056
|
-
|
|
1057
|
-
return epics
|
|
1019
|
+
return [self._milestone_to_epic(milestone) for milestone in response.json()]
|
|
1058
1020
|
|
|
1059
1021
|
async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
|
|
1060
1022
|
"""Link an issue to a pull request using keywords."""
|
|
@@ -1073,9 +1035,9 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1073
1035
|
self,
|
|
1074
1036
|
ticket_id: str,
|
|
1075
1037
|
base_branch: str = "main",
|
|
1076
|
-
head_branch:
|
|
1077
|
-
title:
|
|
1078
|
-
body:
|
|
1038
|
+
head_branch: str | None = None,
|
|
1039
|
+
title: str | None = None,
|
|
1040
|
+
body: str | None = None,
|
|
1079
1041
|
draft: bool = False,
|
|
1080
1042
|
) -> dict[str, Any]:
|
|
1081
1043
|
"""Create a pull request linked to an issue.
|
|
@@ -1094,8 +1056,8 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1094
1056
|
"""
|
|
1095
1057
|
try:
|
|
1096
1058
|
issue_number = int(ticket_id)
|
|
1097
|
-
except ValueError:
|
|
1098
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1059
|
+
except ValueError as e:
|
|
1060
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1099
1061
|
|
|
1100
1062
|
# Get the issue details
|
|
1101
1063
|
issue = await self.read(ticket_id)
|
|
@@ -1229,10 +1191,11 @@ Fixes #{issue_number}
|
|
|
1229
1191
|
pr = pr_response.json()
|
|
1230
1192
|
|
|
1231
1193
|
# Add a comment to the issue about the PR
|
|
1194
|
+
pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
|
|
1232
1195
|
await self.add_comment(
|
|
1233
1196
|
Comment(
|
|
1234
1197
|
ticket_id=ticket_id,
|
|
1235
|
-
content=
|
|
1198
|
+
content=pr_msg,
|
|
1236
1199
|
author="system",
|
|
1237
1200
|
)
|
|
1238
1201
|
)
|
|
@@ -1265,8 +1228,8 @@ Fixes #{issue_number}
|
|
|
1265
1228
|
"""
|
|
1266
1229
|
try:
|
|
1267
1230
|
issue_number = int(ticket_id)
|
|
1268
|
-
except ValueError:
|
|
1269
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1231
|
+
except ValueError as e:
|
|
1232
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1270
1233
|
|
|
1271
1234
|
# Parse PR URL to extract owner, repo, and PR number
|
|
1272
1235
|
# Expected format: https://github.com/owner/repo/pull/123
|
|
@@ -1342,12 +1305,266 @@ Fixes #{issue_number}
|
|
|
1342
1305
|
response.raise_for_status()
|
|
1343
1306
|
return response.json()
|
|
1344
1307
|
|
|
1345
|
-
async def get_current_user(self) ->
|
|
1308
|
+
async def get_current_user(self) -> dict[str, Any] | None:
|
|
1346
1309
|
"""Get current authenticated user information."""
|
|
1347
1310
|
response = await self.client.get("/user")
|
|
1348
1311
|
response.raise_for_status()
|
|
1349
1312
|
return response.json()
|
|
1350
1313
|
|
|
1314
|
+
async def list_labels(self) -> builtins.list[dict[str, Any]]:
|
|
1315
|
+
"""List all labels available in the repository.
|
|
1316
|
+
|
|
1317
|
+
Returns:
|
|
1318
|
+
List of label dictionaries with 'id', 'name', and 'color' fields
|
|
1319
|
+
|
|
1320
|
+
"""
|
|
1321
|
+
if self._labels_cache:
|
|
1322
|
+
return self._labels_cache
|
|
1323
|
+
|
|
1324
|
+
response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
|
|
1325
|
+
response.raise_for_status()
|
|
1326
|
+
labels = response.json()
|
|
1327
|
+
|
|
1328
|
+
# Transform to standardized format
|
|
1329
|
+
standardized_labels = [
|
|
1330
|
+
{"id": label["name"], "name": label["name"], "color": label["color"]}
|
|
1331
|
+
for label in labels
|
|
1332
|
+
]
|
|
1333
|
+
|
|
1334
|
+
self._labels_cache = standardized_labels
|
|
1335
|
+
return standardized_labels
|
|
1336
|
+
|
|
1337
|
+
async def update_milestone(
|
|
1338
|
+
self, milestone_number: int, updates: dict[str, Any]
|
|
1339
|
+
) -> Epic | None:
|
|
1340
|
+
"""Update a GitHub milestone (Epic).
|
|
1341
|
+
|
|
1342
|
+
Args:
|
|
1343
|
+
milestone_number: Milestone number (not ID)
|
|
1344
|
+
updates: Dictionary with fields to update:
|
|
1345
|
+
- title: Milestone title
|
|
1346
|
+
- description: Milestone description (supports markdown)
|
|
1347
|
+
- state: TicketState value (maps to open/closed)
|
|
1348
|
+
- target_date: Due date in ISO format
|
|
1349
|
+
|
|
1350
|
+
Returns:
|
|
1351
|
+
Updated Epic object or None if not found
|
|
1352
|
+
|
|
1353
|
+
Raises:
|
|
1354
|
+
ValueError: If no fields to update
|
|
1355
|
+
httpx.HTTPError: If API request fails
|
|
1356
|
+
|
|
1357
|
+
"""
|
|
1358
|
+
update_data = {}
|
|
1359
|
+
|
|
1360
|
+
# Map title directly
|
|
1361
|
+
if "title" in updates:
|
|
1362
|
+
update_data["title"] = updates["title"]
|
|
1363
|
+
|
|
1364
|
+
# Map description (supports markdown)
|
|
1365
|
+
if "description" in updates:
|
|
1366
|
+
update_data["description"] = updates["description"]
|
|
1367
|
+
|
|
1368
|
+
# Map state to GitHub milestone state
|
|
1369
|
+
if "state" in updates:
|
|
1370
|
+
state = updates["state"]
|
|
1371
|
+
if isinstance(state, TicketState):
|
|
1372
|
+
# GitHub only has open/closed
|
|
1373
|
+
update_data["state"] = (
|
|
1374
|
+
"closed"
|
|
1375
|
+
if state in [TicketState.DONE, TicketState.CLOSED]
|
|
1376
|
+
else "open"
|
|
1377
|
+
)
|
|
1378
|
+
else:
|
|
1379
|
+
update_data["state"] = state
|
|
1380
|
+
|
|
1381
|
+
# Map target_date to due_on
|
|
1382
|
+
if "target_date" in updates:
|
|
1383
|
+
# GitHub expects ISO 8601 format
|
|
1384
|
+
target_date = updates["target_date"]
|
|
1385
|
+
if isinstance(target_date, str):
|
|
1386
|
+
update_data["due_on"] = target_date
|
|
1387
|
+
elif hasattr(target_date, "isoformat"):
|
|
1388
|
+
update_data["due_on"] = target_date.isoformat()
|
|
1389
|
+
|
|
1390
|
+
if not update_data:
|
|
1391
|
+
raise ValueError("At least one field must be updated")
|
|
1392
|
+
|
|
1393
|
+
# Make API request
|
|
1394
|
+
response = await self.client.patch(
|
|
1395
|
+
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
|
|
1396
|
+
json=update_data,
|
|
1397
|
+
)
|
|
1398
|
+
response.raise_for_status()
|
|
1399
|
+
|
|
1400
|
+
# Convert response to Epic
|
|
1401
|
+
milestone_data = response.json()
|
|
1402
|
+
return self._milestone_to_epic(milestone_data)
|
|
1403
|
+
|
|
1404
|
+
async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
|
|
1405
|
+
"""Update a GitHub epic (milestone) by ID or number.
|
|
1406
|
+
|
|
1407
|
+
This is a convenience wrapper around update_milestone() that accepts
|
|
1408
|
+
either a milestone number or the epic ID from the Epic object.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
|
|
1412
|
+
updates: Dictionary with fields to update
|
|
1413
|
+
|
|
1414
|
+
Returns:
|
|
1415
|
+
Updated Epic object or None if not found
|
|
1416
|
+
|
|
1417
|
+
"""
|
|
1418
|
+
# Extract milestone number from ID
|
|
1419
|
+
if epic_id.startswith("milestone-"):
|
|
1420
|
+
milestone_number = int(epic_id.replace("milestone-", ""))
|
|
1421
|
+
else:
|
|
1422
|
+
milestone_number = int(epic_id)
|
|
1423
|
+
|
|
1424
|
+
return await self.update_milestone(milestone_number, updates)
|
|
1425
|
+
|
|
1426
|
+
async def add_attachment_to_issue(
|
|
1427
|
+
self, issue_number: int, file_path: str, comment: str | None = None
|
|
1428
|
+
) -> dict[str, Any]:
|
|
1429
|
+
"""Attach file to GitHub issue via comment.
|
|
1430
|
+
|
|
1431
|
+
GitHub doesn't have direct file attachment API. This method:
|
|
1432
|
+
1. Creates a comment with the file reference
|
|
1433
|
+
2. Returns metadata about the attachment
|
|
1434
|
+
|
|
1435
|
+
Note: GitHub's actual file upload in comments requires browser-based
|
|
1436
|
+
drag-and-drop or git-lfs. This method creates a placeholder comment
|
|
1437
|
+
that users can edit to add actual file attachments through the UI.
|
|
1438
|
+
|
|
1439
|
+
Args:
|
|
1440
|
+
issue_number: Issue number
|
|
1441
|
+
file_path: Path to file to attach
|
|
1442
|
+
comment: Optional comment text (defaults to "Attached: {filename}")
|
|
1443
|
+
|
|
1444
|
+
Returns:
|
|
1445
|
+
Dictionary with comment data and file info
|
|
1446
|
+
|
|
1447
|
+
Raises:
|
|
1448
|
+
FileNotFoundError: If file doesn't exist
|
|
1449
|
+
ValueError: If file too large (>25 MB)
|
|
1450
|
+
|
|
1451
|
+
Note:
|
|
1452
|
+
GitHub file size limit: 25 MB
|
|
1453
|
+
Supported: Images, videos, documents
|
|
1454
|
+
|
|
1455
|
+
"""
|
|
1456
|
+
file_path_obj = Path(file_path)
|
|
1457
|
+
if not file_path_obj.exists():
|
|
1458
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
1459
|
+
|
|
1460
|
+
# Check file size (25 MB limit)
|
|
1461
|
+
file_size = file_path_obj.stat().st_size
|
|
1462
|
+
if file_size > 25 * 1024 * 1024: # 25 MB
|
|
1463
|
+
raise ValueError(
|
|
1464
|
+
f"File too large: {file_size} bytes (max 25 MB). "
|
|
1465
|
+
"Upload file externally and reference URL instead."
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
# Prepare comment body
|
|
1469
|
+
comment_body = comment or f"📎 Attached: `{file_path_obj.name}`"
|
|
1470
|
+
comment_body += (
|
|
1471
|
+
f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
|
|
1472
|
+
"needs to be manually uploaded through GitHub UI or referenced via URL.*"
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
# Create comment with file reference
|
|
1476
|
+
response = await self.client.post(
|
|
1477
|
+
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
1478
|
+
json={"body": comment_body},
|
|
1479
|
+
)
|
|
1480
|
+
response.raise_for_status()
|
|
1481
|
+
|
|
1482
|
+
comment_data = response.json()
|
|
1483
|
+
|
|
1484
|
+
return {
|
|
1485
|
+
"comment_id": comment_data["id"],
|
|
1486
|
+
"comment_url": comment_data["html_url"],
|
|
1487
|
+
"filename": file_path_obj.name,
|
|
1488
|
+
"file_size": file_size,
|
|
1489
|
+
"note": "File reference created. Upload file manually through GitHub UI.",
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async def add_attachment_reference_to_milestone(
|
|
1493
|
+
self, milestone_number: int, file_url: str, description: str
|
|
1494
|
+
) -> Epic | None:
|
|
1495
|
+
"""Add file reference to milestone description.
|
|
1496
|
+
|
|
1497
|
+
Since GitHub milestones don't support direct file attachments,
|
|
1498
|
+
this method appends a markdown link to the milestone description.
|
|
1499
|
+
|
|
1500
|
+
Args:
|
|
1501
|
+
milestone_number: Milestone number
|
|
1502
|
+
file_url: URL to the file (external or GitHub-hosted)
|
|
1503
|
+
description: Description/title for the file
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
Updated Epic object
|
|
1507
|
+
|
|
1508
|
+
Example:
|
|
1509
|
+
await adapter.add_attachment_reference_to_milestone(
|
|
1510
|
+
5,
|
|
1511
|
+
"https://example.com/spec.pdf",
|
|
1512
|
+
"Technical Specification"
|
|
1513
|
+
)
|
|
1514
|
+
# Appends to description: "[Technical Specification](https://example.com/spec.pdf)"
|
|
1515
|
+
|
|
1516
|
+
"""
|
|
1517
|
+
# Get current milestone
|
|
1518
|
+
response = await self.client.get(
|
|
1519
|
+
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
|
|
1520
|
+
)
|
|
1521
|
+
response.raise_for_status()
|
|
1522
|
+
milestone = response.json()
|
|
1523
|
+
|
|
1524
|
+
# Append file reference to description
|
|
1525
|
+
current_desc = milestone.get("description", "")
|
|
1526
|
+
attachment_markdown = f"\n\n📎 [{description}]({file_url})"
|
|
1527
|
+
new_description = current_desc + attachment_markdown
|
|
1528
|
+
|
|
1529
|
+
# Update milestone with new description
|
|
1530
|
+
return await self.update_milestone(
|
|
1531
|
+
milestone_number, {"description": new_description}
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
async def add_attachment(
|
|
1535
|
+
self, ticket_id: str, file_path: str, description: str | None = None
|
|
1536
|
+
) -> dict[str, Any]:
|
|
1537
|
+
"""Add attachment to GitHub ticket (issue or milestone).
|
|
1538
|
+
|
|
1539
|
+
This method routes to appropriate attachment method based on ticket type:
|
|
1540
|
+
- Issues: Creates comment with file reference
|
|
1541
|
+
- Milestones: Not supported, raises NotImplementedError with guidance
|
|
1542
|
+
|
|
1543
|
+
Args:
|
|
1544
|
+
ticket_id: Ticket identifier (issue number or milestone ID)
|
|
1545
|
+
file_path: Path to file to attach
|
|
1546
|
+
description: Optional description
|
|
1547
|
+
|
|
1548
|
+
Returns:
|
|
1549
|
+
Attachment metadata
|
|
1550
|
+
|
|
1551
|
+
Raises:
|
|
1552
|
+
NotImplementedError: For milestones (no native support)
|
|
1553
|
+
FileNotFoundError: If file doesn't exist
|
|
1554
|
+
|
|
1555
|
+
"""
|
|
1556
|
+
# Determine ticket type from ID format
|
|
1557
|
+
if ticket_id.startswith("milestone-"):
|
|
1558
|
+
raise NotImplementedError(
|
|
1559
|
+
"GitHub milestones do not support direct file attachments. "
|
|
1560
|
+
"Workaround: Upload file externally and use "
|
|
1561
|
+
"add_attachment_reference_to_milestone() to add URL to description."
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# Assume it's an issue number
|
|
1565
|
+
issue_number = int(ticket_id.replace("issue-", ""))
|
|
1566
|
+
return await self.add_attachment_to_issue(issue_number, file_path, description)
|
|
1567
|
+
|
|
1351
1568
|
async def close(self) -> None:
|
|
1352
1569
|
"""Close the HTTP client connection."""
|
|
1353
1570
|
await self.client.aclose()
|
mcp_ticketer/adapters/hybrid.py
CHANGED
|
@@ -8,7 +8,7 @@ import builtins
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
from ..core.adapter import BaseAdapter
|
|
14
14
|
from ..core.models import Comment, Epic, SearchQuery, Task, TicketState
|
|
@@ -129,7 +129,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
129
129
|
|
|
130
130
|
def _get_adapter_ticket_id(
|
|
131
131
|
self, universal_id: str, adapter_name: str
|
|
132
|
-
) ->
|
|
132
|
+
) -> str | None:
|
|
133
133
|
"""Get adapter-specific ticket ID from universal ID.
|
|
134
134
|
|
|
135
135
|
Args:
|
|
@@ -153,7 +153,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
153
153
|
|
|
154
154
|
return f"hybrid-{uuid.uuid4().hex[:12]}"
|
|
155
155
|
|
|
156
|
-
async def create(self, ticket:
|
|
156
|
+
async def create(self, ticket: Task | Epic) -> Task | Epic:
|
|
157
157
|
"""Create ticket in all configured adapters.
|
|
158
158
|
|
|
159
159
|
Args:
|
|
@@ -208,7 +208,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
208
208
|
return primary_ticket
|
|
209
209
|
|
|
210
210
|
def _add_cross_references(
|
|
211
|
-
self, ticket:
|
|
211
|
+
self, ticket: Task | Epic, results: list[tuple[str, Task | Epic]]
|
|
212
212
|
) -> None:
|
|
213
213
|
"""Add cross-references to ticket description.
|
|
214
214
|
|
|
@@ -226,7 +226,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
226
226
|
else:
|
|
227
227
|
ticket.description = cross_refs.strip()
|
|
228
228
|
|
|
229
|
-
async def read(self, ticket_id: str) ->
|
|
229
|
+
async def read(self, ticket_id: str) -> Task | Epic | None:
|
|
230
230
|
"""Read ticket from primary adapter.
|
|
231
231
|
|
|
232
232
|
Args:
|
|
@@ -255,7 +255,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
255
255
|
|
|
256
256
|
async def update(
|
|
257
257
|
self, ticket_id: str, updates: dict[str, Any]
|
|
258
|
-
) ->
|
|
258
|
+
) -> Task | Epic | None:
|
|
259
259
|
"""Update ticket across all adapters.
|
|
260
260
|
|
|
261
261
|
Args:
|
|
@@ -300,7 +300,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
300
300
|
|
|
301
301
|
return None
|
|
302
302
|
|
|
303
|
-
def _find_universal_id(self, adapter_ticket_id: str) ->
|
|
303
|
+
def _find_universal_id(self, adapter_ticket_id: str) -> str | None:
|
|
304
304
|
"""Find universal ID for an adapter-specific ticket ID.
|
|
305
305
|
|
|
306
306
|
Args:
|
|
@@ -359,8 +359,8 @@ class HybridAdapter(BaseAdapter):
|
|
|
359
359
|
return success_count > 0
|
|
360
360
|
|
|
361
361
|
async def list(
|
|
362
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
363
|
-
) -> list[
|
|
362
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
363
|
+
) -> list[Task | Epic]:
|
|
364
364
|
"""List tickets from primary adapter.
|
|
365
365
|
|
|
366
366
|
Args:
|
|
@@ -375,7 +375,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
375
375
|
primary = self.adapters[self.primary_adapter_name]
|
|
376
376
|
return await primary.list(limit, offset, filters)
|
|
377
377
|
|
|
378
|
-
async def search(self, query: SearchQuery) -> builtins.list[
|
|
378
|
+
async def search(self, query: SearchQuery) -> builtins.list[Task | Epic]:
|
|
379
379
|
"""Search tickets in primary adapter.
|
|
380
380
|
|
|
381
381
|
Args:
|
|
@@ -390,7 +390,7 @@ class HybridAdapter(BaseAdapter):
|
|
|
390
390
|
|
|
391
391
|
async def transition_state(
|
|
392
392
|
self, ticket_id: str, target_state: TicketState
|
|
393
|
-
) ->
|
|
393
|
+
) -> Task | Epic | None:
|
|
394
394
|
"""Transition ticket state across all adapters.
|
|
395
395
|
|
|
396
396
|
Args:
|