mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__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 (111) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +394 -9
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,10 @@
1
1
  """GitHub adapter implementation using REST API v3 and GraphQL API v4."""
2
2
 
3
3
  import builtins
4
+ import logging
4
5
  import re
5
6
  from datetime import datetime
7
+ from pathlib import Path
6
8
  from typing import Any
7
9
 
8
10
  import httpx
@@ -12,6 +14,8 @@ from ..core.env_loader import load_adapter_config, validate_adapter_config
12
14
  from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
13
15
  from ..core.registry import AdapterRegistry
14
16
 
17
+ logger = logging.getLogger(__name__)
18
+
15
19
 
16
20
  class GitHubStateMapping:
17
21
  """GitHub issue states and label-based extended states."""
@@ -132,6 +136,27 @@ class GitHubGraphQLQueries:
132
136
  }
133
137
  """
134
138
 
139
+ GET_PROJECT_ITERATIONS = """
140
+ query GetProjectIterations($projectId: ID!, $first: Int!, $after: String) {
141
+ node(id: $projectId) {
142
+ ... on ProjectV2 {
143
+ iterations(first: $first, after: $after) {
144
+ nodes {
145
+ id
146
+ title
147
+ startDate
148
+ duration
149
+ }
150
+ pageInfo {
151
+ hasNextPage
152
+ endCursor
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ """
159
+
135
160
 
136
161
  class GitHubAdapter(BaseAdapter[Task]):
137
162
  """Adapter for GitHub Issues tracking system."""
@@ -140,12 +165,13 @@ class GitHubAdapter(BaseAdapter[Task]):
140
165
  """Initialize GitHub adapter.
141
166
 
142
167
  Args:
168
+ ----
143
169
  config: Configuration with:
144
- - token: GitHub Personal Access Token (or GITHUB_TOKEN env var)
170
+ - token: GitHub PAT (or GITHUB_TOKEN env var)
145
171
  - owner: Repository owner (or GITHUB_OWNER env var)
146
172
  - repo: Repository name (or GITHUB_REPO env var)
147
- - api_url: Optional API URL for GitHub Enterprise (defaults to github.com)
148
- - use_projects_v2: Enable GitHub Projects v2 integration (default: False)
173
+ - api_url: Optional API URL for GitHub Enterprise
174
+ - use_projects_v2: Enable Projects v2 (default: False)
149
175
  - custom_priority_scheme: Custom priority label mapping
150
176
 
151
177
  """
@@ -157,11 +183,12 @@ class GitHubAdapter(BaseAdapter[Task]):
157
183
  # Validate required configuration
158
184
  missing_keys = validate_adapter_config("github", full_config)
159
185
  if missing_keys:
186
+ missing = ", ".join(missing_keys)
160
187
  raise ValueError(
161
- f"GitHub adapter missing required configuration: {', '.join(missing_keys)}"
188
+ f"GitHub adapter missing required configuration: {missing}"
162
189
  )
163
190
 
164
- # Get authentication token - support both 'api_key' and 'token' for compatibility
191
+ # Get authentication token - support 'api_key' and 'token'
165
192
  self.token = (
166
193
  full_config.get("api_key")
167
194
  or full_config.get("token")
@@ -206,23 +233,28 @@ class GitHubAdapter(BaseAdapter[Task]):
206
233
  """Validate that required credentials are present.
207
234
 
208
235
  Returns:
236
+ -------
209
237
  (is_valid, error_message) - Tuple of validation result and error message
210
238
 
211
239
  """
212
240
  if not self.token:
213
241
  return (
214
242
  False,
215
- "GITHUB_TOKEN is required but not found. Set it in .env.local or environment.",
243
+ "GITHUB_TOKEN is required. Set it in .env.local or environment.",
216
244
  )
217
245
  if not self.owner:
218
246
  return (
219
247
  False,
220
- "GitHub owner is required in configuration. Set GITHUB_OWNER in .env.local or configure with 'mcp-ticketer init --adapter github --github-owner <owner>'",
248
+ "GitHub owner is required. Set GITHUB_OWNER in .env.local "
249
+ "or configure with 'mcp-ticketer init --adapter github "
250
+ "--github-owner <owner>'",
221
251
  )
222
252
  if not self.repo:
223
253
  return (
224
254
  False,
225
- "GitHub repo is required in configuration. Set GITHUB_REPO in .env.local or configure with 'mcp-ticketer init --adapter github --github-repo <repo>'",
255
+ "GitHub repo is required. Set GITHUB_REPO in .env.local "
256
+ "or configure with 'mcp-ticketer init --adapter github "
257
+ "--github-repo <repo>'",
226
258
  )
227
259
  return True, ""
228
260
 
@@ -278,6 +310,41 @@ class GitHubAdapter(BaseAdapter[Task]):
278
310
  else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
279
311
  )
280
312
 
313
+ def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
314
+ """Convert GitHub milestone to Epic model.
315
+
316
+ Args:
317
+ ----
318
+ milestone: GitHub milestone data
319
+
320
+ Returns:
321
+ -------
322
+ Epic instance
323
+
324
+ """
325
+ return Epic(
326
+ id=str(milestone["number"]),
327
+ title=milestone["title"],
328
+ description=milestone.get("description", ""),
329
+ state=(
330
+ TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
331
+ ),
332
+ created_at=datetime.fromisoformat(
333
+ milestone["created_at"].replace("Z", "+00:00")
334
+ ),
335
+ updated_at=datetime.fromisoformat(
336
+ milestone["updated_at"].replace("Z", "+00:00")
337
+ ),
338
+ metadata={
339
+ "github": {
340
+ "number": milestone["number"],
341
+ "url": milestone.get("html_url"),
342
+ "open_issues": milestone.get("open_issues", 0),
343
+ "closed_issues": milestone.get("closed_issues", 0),
344
+ }
345
+ },
346
+ )
347
+
281
348
  def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
282
349
  """Extract ticket state from GitHub issue data."""
283
350
  # Check if closed
@@ -508,30 +575,72 @@ class GitHubAdapter(BaseAdapter[Task]):
508
575
 
509
576
  return self._task_from_github_issue(created_issue)
510
577
 
511
- async def read(self, ticket_id: str) -> Task | None:
512
- """Read a GitHub issue by number."""
578
+ async def read(self, ticket_id: str) -> Task | Epic | None:
579
+ """Read a GitHub issue OR milestone by number with unified find.
580
+
581
+ Tries to find the entity in the following order:
582
+ 1. Issue (most common case) - returns Task
583
+ 2. Milestone (project/epic) - returns Epic
584
+
585
+ Args:
586
+ ----
587
+ ticket_id: GitHub issue number or milestone number (as string)
588
+
589
+ Returns:
590
+ -------
591
+ Task if issue found,
592
+ Epic if milestone found,
593
+ None if not found as either type
594
+
595
+ Examples:
596
+ --------
597
+ >>> # Read issue #123
598
+ >>> task = await adapter.read("123")
599
+ >>> isinstance(task, Task) # True
600
+ >>>
601
+ >>> # Read milestone #5
602
+ >>> epic = await adapter.read("5")
603
+ >>> isinstance(epic, Epic) # True (if 5 is milestone, not issue)
604
+
605
+ """
513
606
  # Validate credentials before attempting operation
514
607
  is_valid, error_message = self.validate_credentials()
515
608
  if not is_valid:
516
609
  raise ValueError(error_message)
517
610
 
518
611
  try:
519
- issue_number = int(ticket_id)
612
+ entity_number = int(ticket_id)
520
613
  except ValueError:
521
614
  return None
522
615
 
616
+ # Try reading as Issue first (most common case)
523
617
  try:
524
618
  response = await self.client.get(
525
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
619
+ f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
526
620
  )
527
- if response.status_code == 404:
528
- return None
529
- response.raise_for_status()
621
+ if response.status_code == 200:
622
+ response.raise_for_status()
623
+ issue = response.json()
624
+ logger.debug(f"Found GitHub entity as Issue: {ticket_id}")
625
+ return self._task_from_github_issue(issue)
626
+ elif response.status_code == 404:
627
+ # Not found as issue, will try milestone next
628
+ logger.debug(f"Not found as Issue ({ticket_id}), trying Milestone")
629
+ except httpx.HTTPError as e:
630
+ logger.debug(f"Error reading as Issue ({ticket_id}): {e}")
631
+
632
+ # Try reading as Milestone (Epic)
633
+ try:
634
+ milestone = await self.get_milestone(entity_number)
635
+ if milestone:
636
+ logger.debug(f"Found GitHub entity as Milestone: {ticket_id}")
637
+ return milestone
638
+ except Exception as e:
639
+ logger.debug(f"Error reading as Milestone ({ticket_id}): {e}")
530
640
 
531
- issue = response.json()
532
- return self._task_from_github_issue(issue)
533
- except httpx.HTTPError:
534
- return None
641
+ # Not found as either Issue or Milestone
642
+ logger.warning(f"GitHub entity not found: {ticket_id}")
643
+ return None
535
644
 
536
645
  async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
537
646
  """Update a GitHub issue."""
@@ -689,7 +798,7 @@ class GitHubAdapter(BaseAdapter[Task]):
689
798
  ) -> list[Task]:
690
799
  """List GitHub issues with filters."""
691
800
  # Build query parameters
692
- params = {
801
+ params: dict[str, Any] = {
693
802
  "per_page": min(limit, 100), # GitHub max is 100
694
803
  "page": (offset // limit) + 1 if limit > 0 else 1,
695
804
  }
@@ -850,8 +959,8 @@ class GitHubAdapter(BaseAdapter[Task]):
850
959
  """Add a comment to a GitHub issue."""
851
960
  try:
852
961
  issue_number = int(comment.ticket_id)
853
- except ValueError:
854
- raise ValueError(f"Invalid issue number: {comment.ticket_id}")
962
+ except ValueError as e:
963
+ raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
855
964
 
856
965
  # Create comment
857
966
  response = await self.client.post(
@@ -945,31 +1054,7 @@ class GitHubAdapter(BaseAdapter[Task]):
945
1054
  response.raise_for_status()
946
1055
 
947
1056
  created_milestone = response.json()
948
-
949
- return Epic(
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
- )
1057
+ return self._milestone_to_epic(created_milestone)
973
1058
 
974
1059
  async def get_milestone(self, milestone_number: int) -> Epic | None:
975
1060
  """Get a GitHub milestone as an Epic."""
@@ -982,31 +1067,7 @@ class GitHubAdapter(BaseAdapter[Task]):
982
1067
  response.raise_for_status()
983
1068
 
984
1069
  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
- )
1070
+ return self._milestone_to_epic(milestone)
1010
1071
  except httpx.HTTPError:
1011
1072
  return None
1012
1073
 
@@ -1025,36 +1086,63 @@ class GitHubAdapter(BaseAdapter[Task]):
1025
1086
  )
1026
1087
  response.raise_for_status()
1027
1088
 
1028
- epics = []
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
- )
1089
+ return [self._milestone_to_epic(milestone) for milestone in response.json()]
1090
+
1091
+ async def delete_epic(self, epic_id: str) -> bool:
1092
+ """Delete a GitHub milestone (Epic).
1093
+
1094
+ Args:
1095
+ ----
1096
+ epic_id: Milestone number (not ID) as a string
1097
+
1098
+ Returns:
1099
+ -------
1100
+ True if successfully deleted, False otherwise
1101
+
1102
+ Raises:
1103
+ ------
1104
+ ValueError: If credentials are invalid or epic_id is not a valid number
1105
+
1106
+ """
1107
+ # Validate credentials
1108
+ is_valid, error_message = self.validate_credentials()
1109
+ if not is_valid:
1110
+ raise ValueError(error_message)
1111
+
1112
+ try:
1113
+ # Extract milestone number from epic_id
1114
+ milestone_number = int(epic_id)
1115
+ except ValueError as e:
1116
+ raise ValueError(
1117
+ f"Invalid milestone number '{epic_id}'. GitHub milestones use numeric IDs."
1118
+ ) from e
1119
+
1120
+ try:
1121
+ # Delete milestone using REST API
1122
+ response = await self.client.delete(
1123
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
1055
1124
  )
1056
1125
 
1057
- return epics
1126
+ # GitHub returns 204 No Content on successful deletion
1127
+ if response.status_code == 204:
1128
+ return True
1129
+
1130
+ # Handle 404 errors gracefully
1131
+ if response.status_code == 404:
1132
+ return False
1133
+
1134
+ # Other errors - raise for visibility
1135
+ response.raise_for_status()
1136
+ return True
1137
+
1138
+ except httpx.HTTPStatusError as e:
1139
+ if e.response.status_code == 404:
1140
+ # Milestone not found
1141
+ return False
1142
+ # Re-raise other HTTP errors
1143
+ raise ValueError(f"Failed to delete milestone: {e}") from e
1144
+ except Exception as e:
1145
+ raise ValueError(f"Failed to delete milestone: {e}") from e
1058
1146
 
1059
1147
  async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
1060
1148
  """Link an issue to a pull request using keywords."""
@@ -1081,6 +1169,7 @@ class GitHubAdapter(BaseAdapter[Task]):
1081
1169
  """Create a pull request linked to an issue.
1082
1170
 
1083
1171
  Args:
1172
+ ----
1084
1173
  ticket_id: Issue number to link the PR to
1085
1174
  base_branch: Target branch for the PR (default: main)
1086
1175
  head_branch: Source branch name (auto-generated if not provided)
@@ -1089,13 +1178,14 @@ class GitHubAdapter(BaseAdapter[Task]):
1089
1178
  draft: Create as draft PR
1090
1179
 
1091
1180
  Returns:
1181
+ -------
1092
1182
  Dictionary with PR details including number, url, and branch
1093
1183
 
1094
1184
  """
1095
1185
  try:
1096
1186
  issue_number = int(ticket_id)
1097
- except ValueError:
1098
- raise ValueError(f"Invalid issue number: {ticket_id}")
1187
+ except ValueError as e:
1188
+ raise ValueError(f"Invalid issue number: {ticket_id}") from e
1099
1189
 
1100
1190
  # Get the issue details
1101
1191
  issue = await self.read(ticket_id)
@@ -1229,10 +1319,11 @@ Fixes #{issue_number}
1229
1319
  pr = pr_response.json()
1230
1320
 
1231
1321
  # Add a comment to the issue about the PR
1322
+ pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
1232
1323
  await self.add_comment(
1233
1324
  Comment(
1234
1325
  ticket_id=ticket_id,
1235
- content=f"Pull request #{pr['number']} has been created: {pr['html_url']}",
1326
+ content=pr_msg,
1236
1327
  author="system",
1237
1328
  )
1238
1329
  )
@@ -1256,17 +1347,19 @@ Fixes #{issue_number}
1256
1347
  """Link an existing pull request to a ticket.
1257
1348
 
1258
1349
  Args:
1350
+ ----
1259
1351
  ticket_id: Issue number to link the PR to
1260
1352
  pr_url: GitHub PR URL to link
1261
1353
 
1262
1354
  Returns:
1355
+ -------
1263
1356
  Dictionary with link status and PR details
1264
1357
 
1265
1358
  """
1266
1359
  try:
1267
1360
  issue_number = int(ticket_id)
1268
- except ValueError:
1269
- raise ValueError(f"Invalid issue number: {ticket_id}")
1361
+ except ValueError as e:
1362
+ raise ValueError(f"Invalid issue number: {ticket_id}") from e
1270
1363
 
1271
1364
  # Parse PR URL to extract owner, repo, and PR number
1272
1365
  # Expected format: https://github.com/owner/repo/pull/123
@@ -1348,6 +1441,644 @@ Fixes #{issue_number}
1348
1441
  response.raise_for_status()
1349
1442
  return response.json()
1350
1443
 
1444
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
1445
+ """List all labels available in the repository.
1446
+
1447
+ Returns:
1448
+ -------
1449
+ List of label dictionaries with 'id', 'name', and 'color' fields
1450
+
1451
+ """
1452
+ if self._labels_cache:
1453
+ return self._labels_cache
1454
+
1455
+ response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
1456
+ response.raise_for_status()
1457
+ labels = response.json()
1458
+
1459
+ # Transform to standardized format
1460
+ standardized_labels = [
1461
+ {"id": label["name"], "name": label["name"], "color": label["color"]}
1462
+ for label in labels
1463
+ ]
1464
+
1465
+ self._labels_cache = standardized_labels
1466
+ return standardized_labels
1467
+
1468
+ async def update_milestone(
1469
+ self, milestone_number: int, updates: dict[str, Any]
1470
+ ) -> Epic | None:
1471
+ """Update a GitHub milestone (Epic).
1472
+
1473
+ Args:
1474
+ ----
1475
+ milestone_number: Milestone number (not ID)
1476
+ updates: Dictionary with fields to update:
1477
+ - title: Milestone title
1478
+ - description: Milestone description (supports markdown)
1479
+ - state: TicketState value (maps to open/closed)
1480
+ - target_date: Due date in ISO format
1481
+
1482
+ Returns:
1483
+ -------
1484
+ Updated Epic object or None if not found
1485
+
1486
+ Raises:
1487
+ ------
1488
+ ValueError: If no fields to update
1489
+ httpx.HTTPError: If API request fails
1490
+
1491
+ """
1492
+ update_data = {}
1493
+
1494
+ # Map title directly
1495
+ if "title" in updates:
1496
+ update_data["title"] = updates["title"]
1497
+
1498
+ # Map description (supports markdown)
1499
+ if "description" in updates:
1500
+ update_data["description"] = updates["description"]
1501
+
1502
+ # Map state to GitHub milestone state
1503
+ if "state" in updates:
1504
+ state = updates["state"]
1505
+ if isinstance(state, TicketState):
1506
+ # GitHub only has open/closed
1507
+ update_data["state"] = (
1508
+ "closed"
1509
+ if state in [TicketState.DONE, TicketState.CLOSED]
1510
+ else "open"
1511
+ )
1512
+ else:
1513
+ update_data["state"] = state
1514
+
1515
+ # Map target_date to due_on
1516
+ if "target_date" in updates:
1517
+ # GitHub expects ISO 8601 format
1518
+ target_date = updates["target_date"]
1519
+ if isinstance(target_date, str):
1520
+ update_data["due_on"] = target_date
1521
+ elif hasattr(target_date, "isoformat"):
1522
+ update_data["due_on"] = target_date.isoformat()
1523
+
1524
+ if not update_data:
1525
+ raise ValueError("At least one field must be updated")
1526
+
1527
+ # Make API request
1528
+ response = await self.client.patch(
1529
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
1530
+ json=update_data,
1531
+ )
1532
+ response.raise_for_status()
1533
+
1534
+ # Convert response to Epic
1535
+ milestone_data = response.json()
1536
+ return self._milestone_to_epic(milestone_data)
1537
+
1538
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1539
+ """Update a GitHub epic (milestone) by ID or number.
1540
+
1541
+ This is a convenience wrapper around update_milestone() that accepts
1542
+ either a milestone number or the epic ID from the Epic object.
1543
+
1544
+ Args:
1545
+ ----
1546
+ epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
1547
+ updates: Dictionary with fields to update
1548
+
1549
+ Returns:
1550
+ -------
1551
+ Updated Epic object or None if not found
1552
+
1553
+ """
1554
+ # Extract milestone number from ID
1555
+ if epic_id.startswith("milestone-"):
1556
+ milestone_number = int(epic_id.replace("milestone-", ""))
1557
+ else:
1558
+ milestone_number = int(epic_id)
1559
+
1560
+ return await self.update_milestone(milestone_number, updates)
1561
+
1562
+ async def add_attachment_to_issue(
1563
+ self, issue_number: int, file_path: str, comment: str | None = None
1564
+ ) -> dict[str, Any]:
1565
+ """Attach file to GitHub issue via comment.
1566
+
1567
+ GitHub doesn't have direct file attachment API. This method:
1568
+ 1. Creates a comment with the file reference
1569
+ 2. Returns metadata about the attachment
1570
+
1571
+ Note: GitHub's actual file upload in comments requires browser-based
1572
+ drag-and-drop or git-lfs. This method creates a placeholder comment
1573
+ that users can edit to add actual file attachments through the UI.
1574
+
1575
+ Args:
1576
+ ----
1577
+ issue_number: Issue number
1578
+ file_path: Path to file to attach
1579
+ comment: Optional comment text (defaults to "Attached: {filename}")
1580
+
1581
+ Returns:
1582
+ -------
1583
+ Dictionary with comment data and file info
1584
+
1585
+ Raises:
1586
+ ------
1587
+ FileNotFoundError: If file doesn't exist
1588
+ ValueError: If file too large (>25 MB)
1589
+
1590
+ Note:
1591
+ ----
1592
+ GitHub file size limit: 25 MB
1593
+ Supported: Images, videos, documents
1594
+
1595
+ """
1596
+ file_path_obj = Path(file_path)
1597
+ if not file_path_obj.exists():
1598
+ raise FileNotFoundError(f"File not found: {file_path}")
1599
+
1600
+ # Check file size (25 MB limit)
1601
+ file_size = file_path_obj.stat().st_size
1602
+ if file_size > 25 * 1024 * 1024: # 25 MB
1603
+ raise ValueError(
1604
+ f"File too large: {file_size} bytes (max 25 MB). "
1605
+ "Upload file externally and reference URL instead."
1606
+ )
1607
+
1608
+ # Prepare comment body
1609
+ comment_body = comment or f"📎 Attached: `{file_path_obj.name}`"
1610
+ comment_body += (
1611
+ f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
1612
+ "needs to be manually uploaded through GitHub UI or referenced via URL.*"
1613
+ )
1614
+
1615
+ # Create comment with file reference
1616
+ response = await self.client.post(
1617
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
1618
+ json={"body": comment_body},
1619
+ )
1620
+ response.raise_for_status()
1621
+
1622
+ comment_data = response.json()
1623
+
1624
+ return {
1625
+ "comment_id": comment_data["id"],
1626
+ "comment_url": comment_data["html_url"],
1627
+ "filename": file_path_obj.name,
1628
+ "file_size": file_size,
1629
+ "note": "File reference created. Upload file manually through GitHub UI.",
1630
+ }
1631
+
1632
+ async def add_attachment_reference_to_milestone(
1633
+ self, milestone_number: int, file_url: str, description: str
1634
+ ) -> Epic | None:
1635
+ """Add file reference to milestone description.
1636
+
1637
+ Since GitHub milestones don't support direct file attachments,
1638
+ this method appends a markdown link to the milestone description.
1639
+
1640
+ Args:
1641
+ ----
1642
+ milestone_number: Milestone number
1643
+ file_url: URL to the file (external or GitHub-hosted)
1644
+ description: Description/title for the file
1645
+
1646
+ Returns:
1647
+ -------
1648
+ Updated Epic object
1649
+
1650
+ Example:
1651
+ -------
1652
+ await adapter.add_attachment_reference_to_milestone(
1653
+ 5,
1654
+ "https://example.com/spec.pdf",
1655
+ "Technical Specification"
1656
+ )
1657
+ # Appends to description: "[Technical Specification](https://example.com/spec.pdf)"
1658
+
1659
+ """
1660
+ # Get current milestone
1661
+ response = await self.client.get(
1662
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
1663
+ )
1664
+ response.raise_for_status()
1665
+ milestone = response.json()
1666
+
1667
+ # Append file reference to description
1668
+ current_desc = milestone.get("description", "")
1669
+ attachment_markdown = f"\n\n📎 [{description}]({file_url})"
1670
+ new_description = current_desc + attachment_markdown
1671
+
1672
+ # Update milestone with new description
1673
+ return await self.update_milestone(
1674
+ milestone_number, {"description": new_description}
1675
+ )
1676
+
1677
+ async def add_attachment(
1678
+ self, ticket_id: str, file_path: str, description: str | None = None
1679
+ ) -> dict[str, Any]:
1680
+ """Add attachment to GitHub ticket (issue or milestone).
1681
+
1682
+ This method routes to appropriate attachment method based on ticket type:
1683
+ - Issues: Creates comment with file reference
1684
+ - Milestones: Not supported, raises NotImplementedError with guidance
1685
+
1686
+ Args:
1687
+ ----
1688
+ ticket_id: Ticket identifier (issue number or milestone ID)
1689
+ file_path: Path to file to attach
1690
+ description: Optional description
1691
+
1692
+ Returns:
1693
+ -------
1694
+ Attachment metadata
1695
+
1696
+ Raises:
1697
+ ------
1698
+ NotImplementedError: For milestones (no native support)
1699
+ FileNotFoundError: If file doesn't exist
1700
+
1701
+ """
1702
+ # Determine ticket type from ID format
1703
+ if ticket_id.startswith("milestone-"):
1704
+ raise NotImplementedError(
1705
+ "GitHub milestones do not support direct file attachments. "
1706
+ "Workaround: Upload file externally and use "
1707
+ "add_attachment_reference_to_milestone() to add URL to description."
1708
+ )
1709
+
1710
+ # Assume it's an issue number
1711
+ issue_number = int(ticket_id.replace("issue-", ""))
1712
+ return await self.add_attachment_to_issue(issue_number, file_path, description)
1713
+
1714
+ async def list_cycles(
1715
+ self, project_id: str | None = None, limit: int = 50
1716
+ ) -> builtins.list[dict[str, Any]]:
1717
+ """List GitHub Project iterations (cycles/sprints).
1718
+
1719
+ GitHub Projects V2 uses "iterations" for sprint/cycle functionality.
1720
+ Requires a project node ID (not numeric ID).
1721
+
1722
+ Args:
1723
+ ----
1724
+ project_id: GitHub Project V2 node ID (e.g., 'PVT_kwDOABcdefgh').
1725
+ This is required for Projects V2. Can be found in the
1726
+ project's GraphQL ID.
1727
+ limit: Maximum number of iterations to return (default: 50)
1728
+
1729
+ Returns:
1730
+ -------
1731
+ List of iteration dictionaries with fields:
1732
+ - id: Iteration node ID
1733
+ - title: Iteration title/name
1734
+ - startDate: Start date (ISO format)
1735
+ - duration: Duration in days
1736
+ - endDate: Calculated end date (startDate + duration)
1737
+
1738
+ Raises:
1739
+ ------
1740
+ ValueError: If project_id not provided or credentials invalid
1741
+ httpx.HTTPError: If GraphQL query fails
1742
+
1743
+ Example:
1744
+ -------
1745
+ >>> iterations = await adapter.list_cycles(
1746
+ ... project_id="PVT_kwDOABCD1234",
1747
+ ... limit=10
1748
+ ... )
1749
+ >>> for iteration in iterations:
1750
+ ... print(f"{iteration['title']}: {iteration['startDate']} ({iteration['duration']} days)")
1751
+
1752
+ Note:
1753
+ ----
1754
+ GitHub Projects V2 node IDs can be obtained via the GitHub GraphQL API.
1755
+ This is different from project numbers shown in the UI.
1756
+
1757
+ """
1758
+ # Validate credentials
1759
+ is_valid, error_message = self.validate_credentials()
1760
+ if not is_valid:
1761
+ raise ValueError(error_message)
1762
+
1763
+ if not project_id:
1764
+ raise ValueError(
1765
+ "project_id is required for GitHub Projects V2. "
1766
+ "Provide a project node ID (e.g., 'PVT_kwDOABcdefgh'). "
1767
+ "Find this via GraphQL API: query { organization(login: 'org') { "
1768
+ "projectV2(number: 1) { id } } }"
1769
+ )
1770
+
1771
+ # Execute GraphQL query to fetch iterations
1772
+ query = GitHubGraphQLQueries.GET_PROJECT_ITERATIONS
1773
+ variables = {"projectId": project_id, "first": min(limit, 100), "after": None}
1774
+
1775
+ try:
1776
+ result = await self._graphql_request(query, variables)
1777
+
1778
+ # Extract iterations from response
1779
+ project_node = result.get("node")
1780
+ if not project_node:
1781
+ raise ValueError(
1782
+ f"Project not found with ID: {project_id}. "
1783
+ "Verify the project ID is correct and you have access."
1784
+ )
1785
+
1786
+ iterations_data = project_node.get("iterations", {})
1787
+ iteration_nodes = iterations_data.get("nodes", [])
1788
+
1789
+ # Transform to standard format and calculate end dates
1790
+ iterations = []
1791
+ for iteration in iteration_nodes:
1792
+ # Calculate end date from start date + duration
1793
+ start_date = iteration.get("startDate")
1794
+ duration = iteration.get("duration", 0)
1795
+
1796
+ end_date = None
1797
+ if start_date and duration:
1798
+ from datetime import datetime, timedelta
1799
+
1800
+ start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
1801
+ end_dt = start_dt + timedelta(days=duration)
1802
+ end_date = end_dt.isoformat()
1803
+
1804
+ iterations.append(
1805
+ {
1806
+ "id": iteration["id"],
1807
+ "title": iteration.get("title", ""),
1808
+ "startDate": start_date,
1809
+ "duration": duration,
1810
+ "endDate": end_date,
1811
+ }
1812
+ )
1813
+
1814
+ return iterations
1815
+
1816
+ except ValueError:
1817
+ # Re-raise validation errors
1818
+ raise
1819
+ except Exception as e:
1820
+ raise ValueError(f"Failed to list project iterations: {e}") from e
1821
+
1822
+ async def get_issue_status(self, issue_number: int) -> dict[str, Any]:
1823
+ """Get rich status information for a GitHub issue.
1824
+
1825
+ GitHub issues have binary states (open/closed) natively. Extended status
1826
+ tracking uses labels following the status:* convention (e.g., status:in_progress).
1827
+
1828
+ Args:
1829
+ ----
1830
+ issue_number: GitHub issue number
1831
+
1832
+ Returns:
1833
+ -------
1834
+ Dictionary with comprehensive status information:
1835
+ - state: Native GitHub state ('open' or 'closed')
1836
+ - status_label: Extended status from labels (in_progress, blocked, etc.)
1837
+ - extended_state: Universal TicketState value
1838
+ - labels: All issue labels
1839
+ - state_reason: For closed issues (completed or not_planned)
1840
+ - metadata: Additional issue metadata (assignees, milestone, etc.)
1841
+
1842
+ Raises:
1843
+ ------
1844
+ ValueError: If credentials invalid or issue not found
1845
+ httpx.HTTPError: If API request fails
1846
+
1847
+ Example:
1848
+ -------
1849
+ >>> status = await adapter.get_issue_status(123)
1850
+ >>> print(f"Issue #{status['number']}: {status['extended_state']}")
1851
+ >>> print(f"Native state: {status['state']}")
1852
+ >>> if status['status_label']:
1853
+ ... print(f"Label-based status: {status['status_label']}")
1854
+
1855
+ Note:
1856
+ ----
1857
+ GitHub's binary state model is extended via labels:
1858
+ - open + no label = OPEN
1859
+ - open + status:in-progress = IN_PROGRESS
1860
+ - open + status:blocked = BLOCKED
1861
+ - closed = CLOSED (check state_reason for details)
1862
+
1863
+ """
1864
+ # Validate credentials
1865
+ is_valid, error_message = self.validate_credentials()
1866
+ if not is_valid:
1867
+ raise ValueError(error_message)
1868
+
1869
+ try:
1870
+ # Fetch issue via REST API
1871
+ response = await self.client.get(
1872
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
1873
+ )
1874
+
1875
+ if response.status_code == 404:
1876
+ raise ValueError(f"Issue #{issue_number} not found")
1877
+
1878
+ response.raise_for_status()
1879
+ issue = response.json()
1880
+
1881
+ # Extract labels
1882
+ labels = [label["name"] for label in issue.get("labels", [])]
1883
+
1884
+ # Derive extended state from issue data
1885
+ extended_state = self._extract_state_from_issue(issue)
1886
+
1887
+ # Find status label if present
1888
+ status_label = None
1889
+ for _state, label_name in GitHubStateMapping.STATE_LABELS.items():
1890
+ if label_name.lower() in [label.lower() for label in labels]:
1891
+ status_label = label_name
1892
+ break
1893
+
1894
+ # Build comprehensive status response
1895
+ status_info = {
1896
+ "number": issue["number"],
1897
+ "state": issue["state"], # 'open' or 'closed'
1898
+ "status_label": status_label, # Label-based extended status
1899
+ "extended_state": extended_state.value, # Universal TicketState
1900
+ "labels": labels,
1901
+ "state_reason": issue.get(
1902
+ "state_reason"
1903
+ ), # 'completed' or 'not_planned'
1904
+ "metadata": {
1905
+ "title": issue["title"],
1906
+ "url": issue["html_url"],
1907
+ "assignees": [
1908
+ assignee["login"] for assignee in issue.get("assignees", [])
1909
+ ],
1910
+ "milestone": (
1911
+ issue.get("milestone", {}).get("title")
1912
+ if issue.get("milestone")
1913
+ else None
1914
+ ),
1915
+ "created_at": issue["created_at"],
1916
+ "updated_at": issue["updated_at"],
1917
+ "closed_at": issue.get("closed_at"),
1918
+ },
1919
+ }
1920
+
1921
+ return status_info
1922
+
1923
+ except ValueError:
1924
+ # Re-raise validation errors
1925
+ raise
1926
+ except httpx.HTTPError as e:
1927
+ raise ValueError(f"Failed to get issue status: {e}") from e
1928
+
1929
+ async def list_issue_statuses(self) -> builtins.list[dict[str, Any]]:
1930
+ """List available issue statuses in GitHub.
1931
+
1932
+ Returns all possible issue statuses including native GitHub states
1933
+ and extended label-based states.
1934
+
1935
+ Returns:
1936
+ -------
1937
+ List of status dictionaries with fields:
1938
+ - name: Status name (e.g., 'open', 'in_progress', 'closed')
1939
+ - type: Status type ('native' or 'extended')
1940
+ - label: Associated label name (for extended statuses)
1941
+ - description: Human-readable description
1942
+ - category: Status category (open, in_progress, done, etc.)
1943
+
1944
+ Example:
1945
+ -------
1946
+ >>> statuses = await adapter.list_issue_statuses()
1947
+ >>> for status in statuses:
1948
+ ... print(f"{status['name']}: {status['description']}")
1949
+ ... if status['type'] == 'extended':
1950
+ ... print(f" Label: {status['label']}")
1951
+
1952
+ Note:
1953
+ ----
1954
+ GitHub natively supports only 'open' and 'closed' states.
1955
+ Extended statuses are implemented via labels following the
1956
+ status:* naming convention (e.g., status:in-progress).
1957
+
1958
+ """
1959
+ # Define native GitHub states
1960
+ statuses = [
1961
+ {
1962
+ "name": "open",
1963
+ "type": "native",
1964
+ "label": None,
1965
+ "description": "Issue is open and not yet completed",
1966
+ "category": "open",
1967
+ },
1968
+ {
1969
+ "name": "closed",
1970
+ "type": "native",
1971
+ "label": None,
1972
+ "description": "Issue is closed (completed or not planned)",
1973
+ "category": "done",
1974
+ },
1975
+ ]
1976
+
1977
+ # Add extended label-based states
1978
+ for state, label_name in GitHubStateMapping.STATE_LABELS.items():
1979
+ statuses.append(
1980
+ {
1981
+ "name": state.value,
1982
+ "type": "extended",
1983
+ "label": label_name,
1984
+ "description": f"Issue is {state.value.replace('_', ' ')} (tracked via label)",
1985
+ "category": state.value,
1986
+ }
1987
+ )
1988
+
1989
+ return statuses
1990
+
1991
+ async def list_project_labels(
1992
+ self, milestone_number: int | None = None
1993
+ ) -> builtins.list[dict[str, Any]]:
1994
+ """List labels used in a GitHub milestone (project/epic).
1995
+
1996
+ If milestone_number is provided, returns only labels used by issues
1997
+ in that milestone. Otherwise, returns all repository labels.
1998
+
1999
+ Args:
2000
+ ----
2001
+ milestone_number: Optional milestone number to filter labels.
2002
+ If None, returns all repository labels.
2003
+
2004
+ Returns:
2005
+ -------
2006
+ List of label dictionaries with fields:
2007
+ - id: Label identifier (name)
2008
+ - name: Label name
2009
+ - color: Label color (hex without #)
2010
+ - description: Label description (if available)
2011
+ - usage_count: Number of issues using this label (if milestone filtered)
2012
+
2013
+ Example:
2014
+ -------
2015
+ >>> # Get all repository labels
2016
+ >>> all_labels = await adapter.list_project_labels()
2017
+ >>> print(f"Repository has {len(all_labels)} labels")
2018
+ >>>
2019
+ >>> # Get labels used in milestone 5
2020
+ >>> milestone_labels = await adapter.list_project_labels(milestone_number=5)
2021
+ >>> for label in milestone_labels:
2022
+ ... print(f"{label['name']}: used by {label['usage_count']} issues")
2023
+
2024
+ Note:
2025
+ ----
2026
+ Labels are repository-scoped in GitHub, not milestone-scoped.
2027
+ When filtering by milestone, this method queries issues in that
2028
+ milestone and extracts their unique labels.
2029
+
2030
+ """
2031
+ # Validate credentials
2032
+ is_valid, error_message = self.validate_credentials()
2033
+ if not is_valid:
2034
+ raise ValueError(error_message)
2035
+
2036
+ try:
2037
+ if milestone_number is None:
2038
+ # Return all repository labels (delegate to existing method)
2039
+ return await self.list_labels()
2040
+
2041
+ # Query issues in the milestone
2042
+ params = {
2043
+ "milestone": str(milestone_number),
2044
+ "state": "all",
2045
+ "per_page": 100,
2046
+ }
2047
+
2048
+ response = await self.client.get(
2049
+ f"/repos/{self.owner}/{self.repo}/issues", params=params
2050
+ )
2051
+ response.raise_for_status()
2052
+ issues = response.json()
2053
+
2054
+ # Extract unique labels from issues
2055
+ label_usage = {} # {label_name: {data, count}}
2056
+ for issue in issues:
2057
+ # Skip pull requests
2058
+ if "pull_request" in issue:
2059
+ continue
2060
+
2061
+ for label in issue.get("labels", []):
2062
+ label_name = label["name"]
2063
+ if label_name not in label_usage:
2064
+ label_usage[label_name] = {
2065
+ "id": label_name,
2066
+ "name": label_name,
2067
+ "color": label["color"],
2068
+ "description": label.get("description", ""),
2069
+ "usage_count": 0,
2070
+ }
2071
+ label_usage[label_name]["usage_count"] += 1
2072
+
2073
+ # Convert to list and sort by usage count
2074
+ labels = list(label_usage.values())
2075
+ labels.sort(key=lambda x: x["usage_count"], reverse=True)
2076
+
2077
+ return labels
2078
+
2079
+ except httpx.HTTPError as e:
2080
+ raise ValueError(f"Failed to list project labels: {e}") from e
2081
+
1351
2082
  async def close(self) -> None:
1352
2083
  """Close the HTTP client connection."""
1353
2084
  await self.client.aclose()