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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
mcp_ticketer/core/mappers.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
528
|
-
|
|
529
|
-
for
|
|
530
|
-
|
|
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:
|
mcp_ticketer/core/models.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|