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
@@ -98,13 +98,13 @@ class StateMapper(BaseMapper):
98
98
  self._mapping: BiDirectionalDict | None = None
99
99
 
100
100
  @lru_cache(maxsize=1)
101
- def get_mapping(self) -> BiDirectionalDict:
101
+ def get_mapping(self) -> BiDirectionalDict[TicketState, str]:
102
102
  """Get cached bidirectional state mapping."""
103
103
  if self._mapping is not None:
104
104
  return self._mapping
105
105
 
106
106
  # Default mappings by adapter type
107
- default_mappings = {
107
+ default_mappings: dict[str, dict[TicketState, str]] = {
108
108
  "github": {
109
109
  TicketState.OPEN: "open",
110
110
  TicketState.IN_PROGRESS: "open", # Uses labels
@@ -147,13 +147,16 @@ class StateMapper(BaseMapper):
147
147
  },
148
148
  }
149
149
 
150
- mapping = default_mappings.get(self.adapter_type, {})
150
+ mapping: dict[TicketState, str] = default_mappings.get(self.adapter_type, {})
151
151
 
152
- # Apply custom mappings
152
+ # Apply custom mappings (cast to proper type)
153
153
  if self.custom_mappings:
154
- mapping.update(self.custom_mappings)
154
+ # custom_mappings might have str keys, need to convert to TicketState
155
+ for key, value in self.custom_mappings.items():
156
+ if isinstance(key, TicketState):
157
+ mapping[key] = value
155
158
 
156
- self._mapping = BiDirectionalDict(mapping)
159
+ self._mapping = BiDirectionalDict[TicketState, str](mapping)
157
160
  return self._mapping
158
161
 
159
162
  def to_system_state(self, adapter_state: str) -> TicketState:
@@ -168,7 +171,9 @@ class StateMapper(BaseMapper):
168
171
  """
169
172
  cache_key = f"to_system_{adapter_state}"
170
173
  if cache_key in self._cache:
171
- return self._cache[cache_key]
174
+ cached = self._cache[cache_key]
175
+ if isinstance(cached, TicketState):
176
+ return cached
172
177
 
173
178
  mapping = self.get_mapping()
174
179
  result = mapping.get_reverse(adapter_state)
@@ -205,7 +210,9 @@ class StateMapper(BaseMapper):
205
210
  """
206
211
  cache_key = f"from_system_{system_state.value}"
207
212
  if cache_key in self._cache:
208
- return self._cache[cache_key]
213
+ cached = self._cache[cache_key]
214
+ if isinstance(cached, str):
215
+ return cached
209
216
 
210
217
  mapping = self.get_mapping()
211
218
  result = mapping.get_forward(system_state)
@@ -273,13 +280,13 @@ class PriorityMapper(BaseMapper):
273
280
  self._mapping: BiDirectionalDict | None = None
274
281
 
275
282
  @lru_cache(maxsize=1)
276
- def get_mapping(self) -> BiDirectionalDict:
283
+ def get_mapping(self) -> BiDirectionalDict[Priority, Any]:
277
284
  """Get cached bidirectional priority mapping."""
278
285
  if self._mapping is not None:
279
286
  return self._mapping
280
287
 
281
288
  # Default mappings by adapter type
282
- default_mappings = {
289
+ default_mappings: dict[str, dict[Priority, Any]] = {
283
290
  "github": {
284
291
  Priority.CRITICAL: "P0",
285
292
  Priority.HIGH: "P1",
@@ -306,13 +313,16 @@ class PriorityMapper(BaseMapper):
306
313
  },
307
314
  }
308
315
 
309
- mapping = default_mappings.get(self.adapter_type, {})
316
+ mapping: dict[Priority, Any] = default_mappings.get(self.adapter_type, {})
310
317
 
311
- # Apply custom mappings
318
+ # Apply custom mappings (cast to proper type)
312
319
  if self.custom_mappings:
313
- mapping.update(self.custom_mappings)
320
+ # custom_mappings might have str keys, need to convert to Priority
321
+ for key, value in self.custom_mappings.items():
322
+ if isinstance(key, Priority):
323
+ mapping[key] = value
314
324
 
315
- self._mapping = BiDirectionalDict(mapping)
325
+ self._mapping = BiDirectionalDict[Priority, Any](mapping)
316
326
  return self._mapping
317
327
 
318
328
  def to_system_priority(self, adapter_priority: Any) -> Priority:
@@ -327,7 +337,9 @@ class PriorityMapper(BaseMapper):
327
337
  """
328
338
  cache_key = f"to_system_{adapter_priority}"
329
339
  if cache_key in self._cache:
330
- return self._cache[cache_key]
340
+ cached = self._cache[cache_key]
341
+ if isinstance(cached, Priority):
342
+ return cached
331
343
 
332
344
  mapping = self.get_mapping()
333
345
  result = mapping.get_reverse(adapter_priority)
@@ -365,7 +377,7 @@ class PriorityMapper(BaseMapper):
365
377
  ]:
366
378
  result = Priority.LOW
367
379
  break
368
- elif isinstance(adapter_priority, (int, float)):
380
+ elif isinstance(adapter_priority, int | float):
369
381
  # Handle numeric priorities (Linear-style)
370
382
  if adapter_priority <= 1:
371
383
  result = Priority.CRITICAL
@@ -524,10 +536,10 @@ class MapperRegistry:
524
536
  @classmethod
525
537
  def clear_cache(cls) -> None:
526
538
  """Clear all mapper caches."""
527
- for mapper in cls._state_mappers.values():
528
- mapper.clear_cache()
529
- for mapper in cls._priority_mappers.values():
530
- mapper.clear_cache()
539
+ for state_mapper in cls._state_mappers.values():
540
+ state_mapper.clear_cache()
541
+ for priority_mapper in cls._priority_mappers.values():
542
+ priority_mapper.clear_cache()
531
543
 
532
544
  @classmethod
533
545
  def reset(cls) -> None:
@@ -160,6 +160,37 @@ class TicketState(str, Enum):
160
160
  """
161
161
  return target.value in self.valid_transitions().get(self, [])
162
162
 
163
+ def completion_level(self) -> int:
164
+ """Get numeric completion level for state ordering.
165
+
166
+ Higher numbers indicate more complete states. Used for parent/child
167
+ state constraints where parents must be at least as complete as
168
+ their most complete child.
169
+
170
+ Returns:
171
+ Completion level (0-7)
172
+
173
+ Example:
174
+ >>> TicketState.OPEN.completion_level()
175
+ 0
176
+ >>> TicketState.DONE.completion_level()
177
+ 6
178
+ >>> TicketState.DONE.completion_level() > TicketState.IN_PROGRESS.completion_level()
179
+ True
180
+
181
+ """
182
+ levels = {
183
+ TicketState.OPEN: 0, # Not started
184
+ TicketState.BLOCKED: 1, # Blocked
185
+ TicketState.WAITING: 2, # Waiting
186
+ TicketState.IN_PROGRESS: 3, # In progress
187
+ TicketState.READY: 4, # Ready for review
188
+ TicketState.TESTED: 5, # Tested
189
+ TicketState.DONE: 6, # Done
190
+ TicketState.CLOSED: 7, # Closed (terminal)
191
+ }
192
+ return levels.get(self, 0)
193
+
163
194
 
164
195
  class BaseTicket(BaseModel):
165
196
  """Base model for all ticket types with universal field mapping.
@@ -380,11 +411,114 @@ class Attachment(BaseModel):
380
411
  )
381
412
 
382
413
  def __str__(self) -> str:
383
- """String representation showing filename and size."""
414
+ """Return string representation showing filename and size."""
384
415
  size_str = f" ({self.size_bytes} bytes)" if self.size_bytes else ""
385
416
  return f"Attachment({self.filename}{size_str})"
386
417
 
387
418
 
419
+ class ProjectUpdateHealth(str, Enum):
420
+ """Project health status indicator for status updates.
421
+
422
+ Represents the health/status of a project at the time of an update.
423
+ These states map to different platform-specific health indicators:
424
+
425
+ Platform Mappings:
426
+ - Linear: on_track, at_risk, off_track (1:1 mapping)
427
+ - GitHub V2: Uses ProjectV2StatusOptionConfiguration
428
+ - complete: Project is finished
429
+ - inactive: Project is not actively being worked on
430
+ - Asana: On Track, At Risk, Off Track (1:1 mapping)
431
+ - JIRA: Not directly supported (workaround via status comments)
432
+
433
+ Attributes:
434
+ ON_TRACK: Project is progressing as planned
435
+ AT_RISK: Project has some issues but recoverable
436
+ OFF_TRACK: Project is significantly behind or blocked
437
+ COMPLETE: Project is finished (GitHub-specific)
438
+ INACTIVE: Project is not actively being worked on (GitHub-specific)
439
+
440
+ Note:
441
+ Related to ticket 1M-238: Add project updates support with flexible
442
+ project identification.
443
+
444
+ """
445
+
446
+ ON_TRACK = "on_track" # Linear, Asana
447
+ AT_RISK = "at_risk" # Linear, Asana
448
+ OFF_TRACK = "off_track" # Linear, Asana
449
+ COMPLETE = "complete" # GitHub only
450
+ INACTIVE = "inactive" # GitHub only
451
+
452
+
453
+ class ProjectUpdate(BaseModel):
454
+ """Represents a project status update across different platforms.
455
+
456
+ ProjectUpdate provides a unified interface for creating and retrieving
457
+ project status updates with health indicators, supporting Linear, GitHub V2,
458
+ Asana, and JIRA (via workaround).
459
+
460
+ Platform Mappings:
461
+ - Linear: ProjectUpdate entity with health, diff_markdown, staleness
462
+ - GitHub V2: ProjectV2StatusUpdate with status options
463
+ - Asana: Project Status Updates with color-coded health
464
+ - JIRA: Comments with custom formatting (workaround)
465
+
466
+ The model includes platform-specific optional fields to support features
467
+ like Linear's auto-generated diffs and staleness indicators.
468
+
469
+ Attributes:
470
+ id: Unique identifier for the update
471
+ project_id: ID of the project this update belongs to
472
+ project_name: Optional human-readable project name
473
+ body: Markdown-formatted update content (required)
474
+ health: Optional health status indicator
475
+ created_at: Timestamp when update was created
476
+ updated_at: Timestamp when update was last modified
477
+ author_id: Optional ID of the user who created the update
478
+ author_name: Optional human-readable author name
479
+ url: Optional direct URL to the update
480
+ diff_markdown: Linear-specific auto-generated diff of project changes
481
+ is_stale: Linear-specific indicator if update is outdated
482
+
483
+ Example:
484
+ >>> update = ProjectUpdate(
485
+ ... project_id="PROJ-123",
486
+ ... body="Sprint completed with 15/20 stories done",
487
+ ... health=ProjectUpdateHealth.AT_RISK,
488
+ ... created_at=datetime.now()
489
+ ... )
490
+ >>> print(update.model_dump_json())
491
+
492
+ Note:
493
+ Related to ticket 1M-238: Add project updates support with flexible
494
+ project identification.
495
+
496
+ """
497
+
498
+ model_config = ConfigDict(use_enum_values=True)
499
+
500
+ id: str = Field(..., description="Unique update identifier")
501
+ project_id: str = Field(..., description="Parent project identifier")
502
+ project_name: str | None = Field(None, description="Human-readable project name")
503
+ body: str = Field(..., min_length=1, description="Markdown update content")
504
+ health: ProjectUpdateHealth | None = Field(
505
+ None, description="Project health status"
506
+ )
507
+ created_at: datetime = Field(..., description="Creation timestamp")
508
+ updated_at: datetime | None = Field(None, description="Last update timestamp")
509
+ author_id: str | None = Field(None, description="Update author identifier")
510
+ author_name: str | None = Field(None, description="Update author name")
511
+ url: str | None = Field(None, description="Direct URL to update")
512
+
513
+ # Platform-specific fields
514
+ diff_markdown: str | None = Field(
515
+ None, description="Linear: Auto-generated diff of project changes"
516
+ )
517
+ is_stale: bool | None = Field(
518
+ None, description="Linear: Indicator if update is outdated"
519
+ )
520
+
521
+
388
522
  class SearchQuery(BaseModel):
389
523
  """Search query parameters."""
390
524
 
@@ -393,5 +527,6 @@ class SearchQuery(BaseModel):
393
527
  priority: Priority | None = Field(None, description="Filter by priority")
394
528
  tags: list[str] | None = Field(None, description="Filter by tags")
395
529
  assignee: str | None = Field(None, description="Filter by assignee")
530
+ project: str | None = Field(None, description="Filter by project/epic ID or name")
396
531
  limit: int = Field(10, gt=0, le=100, description="Maximum results")
397
532
  offset: int = Field(0, ge=0, description="Result offset for pagination")
@@ -0,0 +1,379 @@
1
+ """1Password CLI integration for secure secret management.
2
+
3
+ This module provides automatic secret loading from 1Password using the op CLI,
4
+ supporting:
5
+ - Detection of op:// secret references in .env files
6
+ - Automatic resolution using `op run` or `op inject`
7
+ - Fallback to regular .env values if 1Password CLI is not available
8
+ - Support for .env.1password template files
9
+ """
10
+
11
+ import logging
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class OnePasswordConfig:
23
+ """Configuration for 1Password integration."""
24
+
25
+ enabled: bool = True
26
+ vault: str | None = None # Default vault for secret references
27
+ service_account_token: str | None = None # For CI/CD environments
28
+ fallback_to_env: bool = True # Fall back to regular .env if op CLI unavailable
29
+
30
+
31
+ class OnePasswordSecretsLoader:
32
+ """Load secrets from 1Password using the op CLI.
33
+
34
+ This class provides methods to:
35
+ 1. Check if 1Password CLI is installed and authenticated
36
+ 2. Resolve op:// secret references in .env files
37
+ 3. Load secrets into environment variables
38
+ 4. Create .env templates with op:// references
39
+ """
40
+
41
+ def __init__(self, config: OnePasswordConfig | None = None) -> None:
42
+ """Initialize the 1Password secrets loader.
43
+
44
+ Args:
45
+ config: Configuration for 1Password integration
46
+
47
+ """
48
+ self.config = config or OnePasswordConfig()
49
+ self._op_available: bool | None = None
50
+ self._op_authenticated: bool | None = None
51
+
52
+ def is_op_available(self) -> bool:
53
+ """Check if 1Password CLI is installed.
54
+
55
+ Returns:
56
+ True if op CLI is available, False otherwise
57
+
58
+ """
59
+ if self._op_available is None:
60
+ self._op_available = shutil.which("op") is not None
61
+ if not self._op_available:
62
+ logger.debug("1Password CLI (op) not found in PATH")
63
+ return self._op_available
64
+
65
+ def is_authenticated(self) -> bool:
66
+ """Check if user is authenticated with 1Password.
67
+
68
+ Returns:
69
+ True if authenticated, False otherwise
70
+
71
+ """
72
+ if not self.is_op_available():
73
+ return False
74
+
75
+ if self._op_authenticated is None:
76
+ try:
77
+ # Try to list accounts to check authentication
78
+ result = subprocess.run(
79
+ ["op", "account", "list"],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ check=False,
84
+ )
85
+ self._op_authenticated = result.returncode == 0
86
+ if not self._op_authenticated:
87
+ logger.debug(
88
+ "1Password CLI not authenticated. Run 'op signin' first."
89
+ )
90
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
91
+ logger.debug(f"Error checking 1Password authentication: {e}")
92
+ self._op_authenticated = False
93
+
94
+ return self._op_authenticated
95
+
96
+ def load_secrets_from_env_file(
97
+ self, env_file: Path, output_dict: dict[str, str] | None = None
98
+ ) -> dict[str, str]:
99
+ """Load secrets from .env file, resolving 1Password references.
100
+
101
+ This method:
102
+ 1. Checks if the .env file contains op:// references
103
+ 2. If yes and op CLI is available, uses op inject to resolve them
104
+ 3. If no op references or CLI unavailable, returns regular dotenv values
105
+
106
+ Args:
107
+ env_file: Path to .env file (may contain op:// references)
108
+ output_dict: Optional dict to update with loaded secrets
109
+
110
+ Returns:
111
+ Dictionary of environment variables with secrets resolved
112
+
113
+ """
114
+ if not env_file.exists():
115
+ logger.warning(f"Environment file not found: {env_file}")
116
+ return output_dict or {}
117
+
118
+ # Read the file to check for op:// references
119
+ content = env_file.read_text(encoding="utf-8")
120
+ has_op_references = "op://" in content
121
+
122
+ if has_op_references and self.is_authenticated():
123
+ # Use op inject to resolve references
124
+ return self._inject_secrets(env_file, output_dict)
125
+ else:
126
+ # Fall back to regular dotenv parsing
127
+ if has_op_references and not self.is_authenticated():
128
+ logger.warning(
129
+ f"File {env_file} contains 1Password references but op CLI "
130
+ "is not authenticated. Using fallback values."
131
+ )
132
+ return self._load_regular_env(env_file, output_dict)
133
+
134
+ def _inject_secrets(
135
+ self, env_file: Path, output_dict: dict[str, str] | None = None
136
+ ) -> dict[str, str]:
137
+ """Use op inject to resolve secret references in .env file.
138
+
139
+ Args:
140
+ env_file: Path to .env file with op:// references
141
+ output_dict: Optional dict to update
142
+
143
+ Returns:
144
+ Dictionary with resolved secrets
145
+
146
+ """
147
+ try:
148
+ # Use op inject to resolve references
149
+ cmd = ["op", "inject", "--in-file", str(env_file)]
150
+
151
+ # Add service account token if provided
152
+ env = None
153
+ if self.config.service_account_token:
154
+ env = {"OP_SERVICE_ACCOUNT_TOKEN": self.config.service_account_token}
155
+
156
+ result = subprocess.run(
157
+ cmd,
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=30,
161
+ check=True,
162
+ env=env,
163
+ )
164
+
165
+ # Parse the injected output
166
+ secrets = self._parse_env_output(result.stdout)
167
+
168
+ if output_dict is not None:
169
+ output_dict.update(secrets)
170
+ return output_dict
171
+ return secrets
172
+
173
+ except subprocess.CalledProcessError as e:
174
+ logger.error(f"Error injecting 1Password secrets: {e.stderr}")
175
+ if self.config.fallback_to_env:
176
+ logger.info("Falling back to regular .env parsing")
177
+ return self._load_regular_env(env_file, output_dict)
178
+ raise
179
+ except subprocess.TimeoutExpired:
180
+ logger.error("Timeout while injecting 1Password secrets")
181
+ if self.config.fallback_to_env:
182
+ return self._load_regular_env(env_file, output_dict)
183
+ raise
184
+
185
+ def _load_regular_env(
186
+ self, env_file: Path, output_dict: dict[str, str] | None = None
187
+ ) -> dict[str, str]:
188
+ """Load environment variables without 1Password resolution.
189
+
190
+ Args:
191
+ env_file: Path to .env file
192
+ output_dict: Optional dict to update
193
+
194
+ Returns:
195
+ Dictionary of environment variables
196
+
197
+ """
198
+ from dotenv import dotenv_values
199
+
200
+ values = dotenv_values(env_file)
201
+
202
+ if output_dict is not None:
203
+ output_dict.update(values)
204
+ return output_dict
205
+ return dict(values)
206
+
207
+ def _parse_env_output(self, output: str) -> dict[str, str]:
208
+ """Parse environment variable output from op inject.
209
+
210
+ Args:
211
+ output: String output from op inject
212
+
213
+ Returns:
214
+ Dictionary of parsed environment variables
215
+
216
+ """
217
+ env_vars = {}
218
+ for line in output.splitlines():
219
+ line = line.strip()
220
+ if not line or line.startswith("#"):
221
+ continue
222
+
223
+ # Split on first = only
224
+ if "=" in line:
225
+ key, value = line.split("=", 1)
226
+ key = key.strip()
227
+ value = value.strip()
228
+
229
+ # Remove quotes if present
230
+ if value.startswith('"') and value.endswith('"'):
231
+ value = value[1:-1]
232
+ elif value.startswith("'") and value.endswith("'"):
233
+ value = value[1:-1]
234
+
235
+ env_vars[key] = value
236
+
237
+ return env_vars
238
+
239
+ def create_template_file(
240
+ self,
241
+ output_path: Path,
242
+ adapter_type: str,
243
+ vault_name: str = "Development",
244
+ item_name: str | None = None,
245
+ ) -> None:
246
+ """Create a .env template file with 1Password secret references.
247
+
248
+ Args:
249
+ output_path: Path where to create the template file
250
+ adapter_type: Type of adapter (linear, github, jira, aitrackdown)
251
+ vault_name: Name of 1Password vault to use
252
+ item_name: Name of 1Password item (defaults to adapter name)
253
+
254
+ """
255
+ if item_name is None:
256
+ item_name = adapter_type.upper()
257
+
258
+ templates = {
259
+ "linear": f"""# Linear Configuration with 1Password
260
+ # This file contains secret references that will be resolved by 1Password CLI
261
+ # Run: op run --env-file=.env.1password -- mcp-ticketer discover
262
+
263
+ LINEAR_API_KEY="op://{vault_name}/{item_name}/api_key"
264
+ LINEAR_TEAM_ID="op://{vault_name}/{item_name}/team_id"
265
+ LINEAR_TEAM_KEY="op://{vault_name}/{item_name}/team_key"
266
+ LINEAR_PROJECT_ID="op://{vault_name}/{item_name}/project_id"
267
+ """,
268
+ "github": f"""# GitHub Configuration with 1Password
269
+ # This file contains secret references that will be resolved by 1Password CLI
270
+ # Run: op run --env-file=.env.1password -- mcp-ticketer discover
271
+
272
+ GITHUB_TOKEN="op://{vault_name}/{item_name}/token"
273
+ GITHUB_OWNER="op://{vault_name}/{item_name}/owner"
274
+ GITHUB_REPO="op://{vault_name}/{item_name}/repo"
275
+ """,
276
+ "jira": f"""# JIRA Configuration with 1Password
277
+ # This file contains secret references that will be resolved by 1Password CLI
278
+ # Run: op run --env-file=.env.1password -- mcp-ticketer discover
279
+
280
+ JIRA_SERVER="op://{vault_name}/{item_name}/server"
281
+ JIRA_EMAIL="op://{vault_name}/{item_name}/email"
282
+ JIRA_API_TOKEN="op://{vault_name}/{item_name}/api_token"
283
+ JIRA_PROJECT_KEY="op://{vault_name}/{item_name}/project_key"
284
+ """,
285
+ "aitrackdown": """# AITrackdown Configuration
286
+ # AITrackdown doesn't use API keys, but you can store the base path
287
+
288
+ AITRACKDOWN_PATH=".aitrackdown"
289
+ """,
290
+ }
291
+
292
+ template = templates.get(adapter_type.lower(), "")
293
+ if template:
294
+ output_path.write_text(template, encoding="utf-8")
295
+ logger.info(f"Created 1Password template file: {output_path}")
296
+ else:
297
+ logger.error(f"Unknown adapter type: {adapter_type}")
298
+
299
+ def run_with_secrets(
300
+ self, command: list[str], env_file: Path | None = None
301
+ ) -> subprocess.CompletedProcess[str]:
302
+ """Run a command with secrets loaded from 1Password.
303
+
304
+ Args:
305
+ command: Command and arguments to run
306
+ env_file: Optional .env file with secret references
307
+
308
+ Returns:
309
+ CompletedProcess result
310
+
311
+ """
312
+ if not self.is_authenticated():
313
+ raise RuntimeError(
314
+ "1Password CLI not authenticated. Run 'op signin' first."
315
+ )
316
+
317
+ cmd = ["op", "run"]
318
+
319
+ if env_file:
320
+ cmd.extend(["--env-file", str(env_file)])
321
+
322
+ cmd.append("--")
323
+ cmd.extend(command)
324
+
325
+ return subprocess.run(cmd, capture_output=True, text=True, check=True)
326
+
327
+
328
+ def check_op_cli_status() -> dict[str, Any]:
329
+ """Check the status of 1Password CLI installation and authentication.
330
+
331
+ Returns:
332
+ Dictionary with status information
333
+
334
+ """
335
+ loader = OnePasswordSecretsLoader()
336
+
337
+ status: dict[str, Any] = {
338
+ "installed": loader.is_op_available(),
339
+ "authenticated": False,
340
+ "version": None,
341
+ "accounts": [],
342
+ }
343
+
344
+ if status["installed"]:
345
+ # Get version
346
+ try:
347
+ result = subprocess.run(
348
+ ["op", "--version"],
349
+ capture_output=True,
350
+ text=True,
351
+ timeout=5,
352
+ check=False,
353
+ )
354
+ if result.returncode == 0:
355
+ status["version"] = result.stdout.strip()
356
+ except (subprocess.TimeoutExpired, FileNotFoundError):
357
+ pass
358
+
359
+ # Check authentication
360
+ status["authenticated"] = loader.is_authenticated()
361
+
362
+ # Get accounts if authenticated
363
+ if status["authenticated"]:
364
+ try:
365
+ result = subprocess.run(
366
+ ["op", "account", "list", "--format=json"],
367
+ capture_output=True,
368
+ text=True,
369
+ timeout=5,
370
+ check=False,
371
+ )
372
+ if result.returncode == 0:
373
+ import json
374
+
375
+ status["accounts"] = json.loads(result.stdout)
376
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
377
+ pass
378
+
379
+ return status