mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Ticket instructions management commands.
|
|
2
|
+
|
|
3
|
+
This module implements CLI commands for managing ticket writing instructions,
|
|
4
|
+
allowing users to customize and view the guidelines that help create
|
|
5
|
+
well-structured, consistent tickets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from ..core.instructions import (
|
|
17
|
+
InstructionsError,
|
|
18
|
+
InstructionsValidationError,
|
|
19
|
+
TicketInstructionsManager,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="instructions",
|
|
24
|
+
help="Manage ticket writing instructions for your project",
|
|
25
|
+
)
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def show(
|
|
31
|
+
default: bool = typer.Option(
|
|
32
|
+
False,
|
|
33
|
+
"--default",
|
|
34
|
+
help="Show default instructions instead of custom",
|
|
35
|
+
),
|
|
36
|
+
raw: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--raw",
|
|
39
|
+
help="Output raw markdown without formatting",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Display current ticket writing instructions.
|
|
43
|
+
|
|
44
|
+
By default, shows custom instructions if they exist, otherwise shows defaults.
|
|
45
|
+
Use --default to always show the default instructions.
|
|
46
|
+
Use --raw to output raw markdown without Rich formatting (useful for piping).
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
# Show current instructions (custom or default)
|
|
50
|
+
mcp-ticketer instructions show
|
|
51
|
+
|
|
52
|
+
# Always show default instructions
|
|
53
|
+
mcp-ticketer instructions show --default
|
|
54
|
+
|
|
55
|
+
# Output raw markdown for piping
|
|
56
|
+
mcp-ticketer instructions show --raw > team_guide.md
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
manager = TicketInstructionsManager()
|
|
61
|
+
|
|
62
|
+
if default:
|
|
63
|
+
instructions = manager.get_default_instructions()
|
|
64
|
+
source = "default"
|
|
65
|
+
else:
|
|
66
|
+
instructions = manager.get_instructions()
|
|
67
|
+
source = "custom" if manager.has_custom_instructions() else "default"
|
|
68
|
+
|
|
69
|
+
if raw:
|
|
70
|
+
# Raw output for piping
|
|
71
|
+
console.print(instructions)
|
|
72
|
+
else:
|
|
73
|
+
# Rich formatted output
|
|
74
|
+
if source == "custom":
|
|
75
|
+
title = f"[green]Custom Instructions[/green] ({manager.get_instructions_path()})"
|
|
76
|
+
else:
|
|
77
|
+
title = "[blue]Default Instructions[/blue]"
|
|
78
|
+
|
|
79
|
+
panel = Panel(
|
|
80
|
+
Markdown(instructions),
|
|
81
|
+
title=title,
|
|
82
|
+
border_style="cyan",
|
|
83
|
+
)
|
|
84
|
+
console.print(panel)
|
|
85
|
+
|
|
86
|
+
except InstructionsError as e:
|
|
87
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
88
|
+
raise typer.Exit(1) from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def add(
|
|
93
|
+
file_path: str | None = typer.Argument(
|
|
94
|
+
None,
|
|
95
|
+
help="Path to markdown file with custom instructions",
|
|
96
|
+
),
|
|
97
|
+
stdin: bool = typer.Option(
|
|
98
|
+
False,
|
|
99
|
+
"--stdin",
|
|
100
|
+
help="Read instructions from stdin instead of file",
|
|
101
|
+
),
|
|
102
|
+
force: bool = typer.Option(
|
|
103
|
+
False,
|
|
104
|
+
"--force",
|
|
105
|
+
"-f",
|
|
106
|
+
help="Overwrite existing custom instructions without confirmation",
|
|
107
|
+
),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Add custom ticket writing instructions for your project.
|
|
110
|
+
|
|
111
|
+
You can provide instructions from a file or via stdin. If custom instructions
|
|
112
|
+
already exist, you'll be prompted for confirmation unless --force is used.
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
# Add from file
|
|
116
|
+
mcp-ticketer instructions add team_guidelines.md
|
|
117
|
+
|
|
118
|
+
# Add from stdin
|
|
119
|
+
cat guidelines.md | mcp-ticketer instructions add --stdin
|
|
120
|
+
|
|
121
|
+
# Force overwrite existing
|
|
122
|
+
mcp-ticketer instructions add new_guide.md --force
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
manager = TicketInstructionsManager()
|
|
127
|
+
|
|
128
|
+
# Check for existing custom instructions
|
|
129
|
+
if manager.has_custom_instructions() and not force:
|
|
130
|
+
path = manager.get_instructions_path()
|
|
131
|
+
console.print(
|
|
132
|
+
f"[yellow]Warning:[/yellow] Custom instructions already exist at {path}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
confirm = typer.confirm("Do you want to overwrite them?")
|
|
136
|
+
if not confirm:
|
|
137
|
+
console.print("[yellow]Operation cancelled[/yellow]")
|
|
138
|
+
raise typer.Exit(0) from None
|
|
139
|
+
|
|
140
|
+
# Get content from stdin or file
|
|
141
|
+
if stdin:
|
|
142
|
+
console.print("[dim]Reading from stdin... (Press Ctrl+D when done)[/dim]")
|
|
143
|
+
content = sys.stdin.read()
|
|
144
|
+
if not content.strip():
|
|
145
|
+
console.print("[red]Error:[/red] No content provided on stdin")
|
|
146
|
+
raise typer.Exit(1) from None
|
|
147
|
+
elif file_path:
|
|
148
|
+
source_path = Path(file_path)
|
|
149
|
+
if not source_path.exists():
|
|
150
|
+
console.print(f"[red]Error:[/red] File not found: {file_path}")
|
|
151
|
+
raise typer.Exit(1) from None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
content = source_path.read_text(encoding="utf-8")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
console.print(f"[red]Error:[/red] Failed to read file: {e}")
|
|
157
|
+
raise typer.Exit(1) from None
|
|
158
|
+
else:
|
|
159
|
+
console.print("[red]Error:[/red] Either provide a file path or use --stdin")
|
|
160
|
+
console.print("Example: mcp-ticketer instructions add guidelines.md")
|
|
161
|
+
raise typer.Exit(1) from None
|
|
162
|
+
|
|
163
|
+
# Set instructions
|
|
164
|
+
manager.set_instructions(content)
|
|
165
|
+
|
|
166
|
+
path = manager.get_instructions_path()
|
|
167
|
+
console.print(f"[green]✓[/green] Custom instructions saved to: {path}")
|
|
168
|
+
console.print("[dim]Use 'mcp-ticketer instructions show' to view them[/dim]")
|
|
169
|
+
|
|
170
|
+
except InstructionsValidationError as e:
|
|
171
|
+
console.print(f"[red]Validation Error:[/red] {e}")
|
|
172
|
+
raise typer.Exit(1) from None
|
|
173
|
+
except InstructionsError as e:
|
|
174
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
175
|
+
raise typer.Exit(1) from None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def update(
|
|
180
|
+
file_path: str | None = typer.Argument(
|
|
181
|
+
None,
|
|
182
|
+
help="Path to markdown file with updated instructions",
|
|
183
|
+
),
|
|
184
|
+
stdin: bool = typer.Option(
|
|
185
|
+
False,
|
|
186
|
+
"--stdin",
|
|
187
|
+
help="Read instructions from stdin instead of file",
|
|
188
|
+
),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Update existing custom instructions (alias for 'add --force').
|
|
191
|
+
|
|
192
|
+
This is a convenience command that overwrites existing custom instructions
|
|
193
|
+
without prompting for confirmation.
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
# Update from file
|
|
197
|
+
mcp-ticketer instructions update new_guidelines.md
|
|
198
|
+
|
|
199
|
+
# Update from stdin
|
|
200
|
+
cat updated.md | mcp-ticketer instructions update --stdin
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
manager = TicketInstructionsManager()
|
|
205
|
+
|
|
206
|
+
if not manager.has_custom_instructions():
|
|
207
|
+
console.print("[yellow]Warning:[/yellow] No custom instructions exist yet")
|
|
208
|
+
console.print("Use 'mcp-ticketer instructions add' to create them first")
|
|
209
|
+
raise typer.Exit(1) from None
|
|
210
|
+
|
|
211
|
+
# Get content from stdin or file
|
|
212
|
+
if stdin:
|
|
213
|
+
console.print("[dim]Reading from stdin... (Press Ctrl+D when done)[/dim]")
|
|
214
|
+
content = sys.stdin.read()
|
|
215
|
+
if not content.strip():
|
|
216
|
+
console.print("[red]Error:[/red] No content provided on stdin")
|
|
217
|
+
raise typer.Exit(1) from None
|
|
218
|
+
elif file_path:
|
|
219
|
+
source_path = Path(file_path)
|
|
220
|
+
if not source_path.exists():
|
|
221
|
+
console.print(f"[red]Error:[/red] File not found: {file_path}")
|
|
222
|
+
raise typer.Exit(1) from None
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
content = source_path.read_text(encoding="utf-8")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
console.print(f"[red]Error:[/red] Failed to read file: {e}")
|
|
228
|
+
raise typer.Exit(1) from None
|
|
229
|
+
else:
|
|
230
|
+
console.print("[red]Error:[/red] Either provide a file path or use --stdin")
|
|
231
|
+
console.print("Example: mcp-ticketer instructions update guidelines.md")
|
|
232
|
+
raise typer.Exit(1) from None
|
|
233
|
+
|
|
234
|
+
# Update instructions (force overwrite)
|
|
235
|
+
manager.set_instructions(content)
|
|
236
|
+
|
|
237
|
+
path = manager.get_instructions_path()
|
|
238
|
+
console.print(f"[green]✓[/green] Custom instructions updated: {path}")
|
|
239
|
+
console.print("[dim]Use 'mcp-ticketer instructions show' to view them[/dim]")
|
|
240
|
+
|
|
241
|
+
except InstructionsValidationError as e:
|
|
242
|
+
console.print(f"[red]Validation Error:[/red] {e}")
|
|
243
|
+
raise typer.Exit(1) from None
|
|
244
|
+
except InstructionsError as e:
|
|
245
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
246
|
+
raise typer.Exit(1) from None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.command()
|
|
250
|
+
def delete(
|
|
251
|
+
yes: bool = typer.Option(
|
|
252
|
+
False,
|
|
253
|
+
"--yes",
|
|
254
|
+
"-y",
|
|
255
|
+
help="Skip confirmation prompt",
|
|
256
|
+
),
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Delete custom instructions and revert to defaults.
|
|
259
|
+
|
|
260
|
+
This removes your project-specific instructions file. After deletion,
|
|
261
|
+
the default instructions will be used.
|
|
262
|
+
|
|
263
|
+
Examples:
|
|
264
|
+
# Delete with confirmation prompt
|
|
265
|
+
mcp-ticketer instructions delete
|
|
266
|
+
|
|
267
|
+
# Skip confirmation
|
|
268
|
+
mcp-ticketer instructions delete --yes
|
|
269
|
+
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
manager = TicketInstructionsManager()
|
|
273
|
+
|
|
274
|
+
if not manager.has_custom_instructions():
|
|
275
|
+
console.print("[yellow]No custom instructions to delete[/yellow]")
|
|
276
|
+
console.print("[dim]Already using default instructions[/dim]")
|
|
277
|
+
raise typer.Exit(0) from None
|
|
278
|
+
|
|
279
|
+
path = manager.get_instructions_path()
|
|
280
|
+
|
|
281
|
+
if not yes:
|
|
282
|
+
console.print(f"[yellow]Warning:[/yellow] This will delete: {path}")
|
|
283
|
+
console.print("After deletion, default instructions will be used.")
|
|
284
|
+
|
|
285
|
+
confirm = typer.confirm("Are you sure?")
|
|
286
|
+
if not confirm:
|
|
287
|
+
console.print("[yellow]Operation cancelled[/yellow]")
|
|
288
|
+
raise typer.Exit(0) from None
|
|
289
|
+
|
|
290
|
+
# Delete instructions
|
|
291
|
+
manager.delete_instructions()
|
|
292
|
+
|
|
293
|
+
console.print("[green]✓[/green] Custom instructions deleted")
|
|
294
|
+
console.print("[dim]Now using default instructions[/dim]")
|
|
295
|
+
|
|
296
|
+
except InstructionsError as e:
|
|
297
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
298
|
+
raise typer.Exit(1) from None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command()
|
|
302
|
+
def path() -> None:
|
|
303
|
+
"""Show path to custom instructions file.
|
|
304
|
+
|
|
305
|
+
Displays the path where custom instructions are (or would be) stored
|
|
306
|
+
for this project, along with status information.
|
|
307
|
+
|
|
308
|
+
Examples:
|
|
309
|
+
# Show instructions file path
|
|
310
|
+
mcp-ticketer instructions path
|
|
311
|
+
|
|
312
|
+
# Use in scripts
|
|
313
|
+
INST_PATH=$(mcp-ticketer instructions path --quiet)
|
|
314
|
+
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
manager = TicketInstructionsManager()
|
|
318
|
+
inst_path = manager.get_instructions_path()
|
|
319
|
+
exists = manager.has_custom_instructions()
|
|
320
|
+
|
|
321
|
+
console.print(f"[cyan]Instructions file:[/cyan] {inst_path}")
|
|
322
|
+
|
|
323
|
+
if exists:
|
|
324
|
+
console.print("[green]Status:[/green] Custom instructions exist")
|
|
325
|
+
|
|
326
|
+
# Show file size
|
|
327
|
+
try:
|
|
328
|
+
size = inst_path.stat().st_size
|
|
329
|
+
console.print(f"[dim]Size: {size} bytes[/dim]")
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
else:
|
|
333
|
+
console.print(
|
|
334
|
+
"[yellow]Status:[/yellow] No custom instructions (using defaults)"
|
|
335
|
+
)
|
|
336
|
+
console.print(
|
|
337
|
+
"[dim]Create with: mcp-ticketer instructions add <file>[/dim]"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
except InstructionsError as e:
|
|
341
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
342
|
+
raise typer.Exit(1) from None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.command()
|
|
346
|
+
def edit() -> None:
|
|
347
|
+
"""Open instructions in default editor.
|
|
348
|
+
|
|
349
|
+
Opens the custom instructions file in your system's default text editor.
|
|
350
|
+
If custom instructions don't exist yet, creates them with default content
|
|
351
|
+
first.
|
|
352
|
+
|
|
353
|
+
The editor is determined by the EDITOR environment variable, or falls back
|
|
354
|
+
to sensible defaults (vim on Unix, notepad on Windows).
|
|
355
|
+
|
|
356
|
+
Examples:
|
|
357
|
+
# Edit instructions
|
|
358
|
+
mcp-ticketer instructions edit
|
|
359
|
+
|
|
360
|
+
# Use specific editor
|
|
361
|
+
EDITOR=nano mcp-ticketer instructions edit
|
|
362
|
+
|
|
363
|
+
"""
|
|
364
|
+
import os
|
|
365
|
+
import platform
|
|
366
|
+
import subprocess
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
manager = TicketInstructionsManager()
|
|
370
|
+
|
|
371
|
+
# If no custom instructions exist, create them with defaults
|
|
372
|
+
if not manager.has_custom_instructions():
|
|
373
|
+
console.print("[yellow]No custom instructions yet[/yellow]")
|
|
374
|
+
console.print("[dim]Creating from defaults...[/dim]")
|
|
375
|
+
|
|
376
|
+
# Copy defaults to custom location
|
|
377
|
+
default_content = manager.get_default_instructions()
|
|
378
|
+
manager.set_instructions(default_content)
|
|
379
|
+
|
|
380
|
+
console.print(
|
|
381
|
+
f"[green]✓[/green] Created custom instructions at: {manager.get_instructions_path()}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
inst_path = manager.get_instructions_path()
|
|
385
|
+
|
|
386
|
+
# Determine editor
|
|
387
|
+
editor = os.environ.get("EDITOR")
|
|
388
|
+
|
|
389
|
+
if not editor:
|
|
390
|
+
# Platform-specific defaults
|
|
391
|
+
system = platform.system()
|
|
392
|
+
if system == "Windows":
|
|
393
|
+
editor = "notepad"
|
|
394
|
+
else:
|
|
395
|
+
# Unix-like: try common editors
|
|
396
|
+
for candidate in ["vim", "vi", "nano", "emacs"]:
|
|
397
|
+
try:
|
|
398
|
+
result = subprocess.run(
|
|
399
|
+
["which", candidate],
|
|
400
|
+
capture_output=True,
|
|
401
|
+
text=True,
|
|
402
|
+
timeout=1,
|
|
403
|
+
)
|
|
404
|
+
if result.returncode == 0:
|
|
405
|
+
editor = candidate
|
|
406
|
+
break
|
|
407
|
+
except Exception:
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
if not editor:
|
|
411
|
+
editor = "vi" # Ultimate fallback
|
|
412
|
+
|
|
413
|
+
console.print(f"[dim]Opening with {editor}...[/dim]")
|
|
414
|
+
|
|
415
|
+
# Open editor
|
|
416
|
+
try:
|
|
417
|
+
subprocess.run([editor, str(inst_path)], check=True)
|
|
418
|
+
console.print(f"[green]✓[/green] Finished editing: {inst_path}")
|
|
419
|
+
except subprocess.CalledProcessError as e:
|
|
420
|
+
console.print(f"[red]Error:[/red] Editor exited with code {e.returncode}")
|
|
421
|
+
raise typer.Exit(1) from None
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
console.print(f"[red]Error:[/red] Editor not found: {editor}")
|
|
424
|
+
console.print("Set EDITOR environment variable to your preferred editor")
|
|
425
|
+
raise typer.Exit(1) from None
|
|
426
|
+
|
|
427
|
+
except InstructionsError as e:
|
|
428
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
429
|
+
raise typer.Exit(1) from None
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Linear-specific CLI commands for workspace and team management."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
|
|
4
|
+
import re
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
from gql import Client, gql
|
|
@@ -13,13 +13,96 @@ app = typer.Typer(name="linear", help="Linear workspace and team management")
|
|
|
13
13
|
console = Console()
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
async def derive_team_from_url(
|
|
17
|
+
api_key: str, team_url: str
|
|
18
|
+
) -> tuple[str | None, str | None]:
|
|
19
|
+
"""Derive team ID from Linear team issues URL.
|
|
20
|
+
|
|
21
|
+
Accepts URLs like:
|
|
22
|
+
- https://linear.app/1m-hyperdev/team/1M/active
|
|
23
|
+
- https://linear.app/1m-hyperdev/team/1M/
|
|
24
|
+
- https://linear.app/1m-hyperdev/team/1M
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
api_key: Linear API key
|
|
28
|
+
team_url: URL to Linear team issues page
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (team_id, error_message). If successful, team_id is set and error_message is None.
|
|
32
|
+
If failed, team_id is None and error_message contains the error.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
# Extract team key from URL using regex
|
|
36
|
+
# Pattern: https://linear.app/<workspace>/team/<TEAM_KEY>/...
|
|
37
|
+
pattern = r"https://linear\.app/[\w-]+/team/([\w-]+)"
|
|
38
|
+
match = re.search(pattern, team_url)
|
|
39
|
+
|
|
40
|
+
if not match:
|
|
41
|
+
return (
|
|
42
|
+
None,
|
|
43
|
+
"Invalid Linear team URL format. Expected: https://linear.app/<workspace>/team/<TEAM_KEY>",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
team_key = match.group(1)
|
|
47
|
+
console.print(f"[dim]Extracted team key: {team_key}[/dim]")
|
|
48
|
+
|
|
49
|
+
# Query Linear API to resolve team key to team ID
|
|
50
|
+
query = gql(
|
|
51
|
+
"""
|
|
52
|
+
query GetTeamByKey($key: String!) {
|
|
53
|
+
teams(filter: { key: { eq: $key } }) {
|
|
54
|
+
nodes {
|
|
55
|
+
id
|
|
56
|
+
key
|
|
57
|
+
name
|
|
58
|
+
organization {
|
|
59
|
+
name
|
|
60
|
+
urlKey
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Create client
|
|
70
|
+
transport = HTTPXTransport(
|
|
71
|
+
url="https://api.linear.app/graphql", headers={"Authorization": api_key}
|
|
72
|
+
)
|
|
73
|
+
client = Client(transport=transport, fetch_schema_from_transport=False)
|
|
74
|
+
|
|
75
|
+
# Execute query
|
|
76
|
+
result = client.execute(query, variable_values={"key": team_key})
|
|
77
|
+
teams = result.get("teams", {}).get("nodes", [])
|
|
78
|
+
|
|
79
|
+
if not teams:
|
|
80
|
+
return (
|
|
81
|
+
None,
|
|
82
|
+
f"Team with key '{team_key}' not found. Please check your team URL and API key.",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
team = teams[0]
|
|
86
|
+
team_id = team["id"]
|
|
87
|
+
team_name = team["name"]
|
|
88
|
+
|
|
89
|
+
console.print(
|
|
90
|
+
f"[green]✓[/green] Resolved team: {team_name} (Key: {team_key}, ID: {team_id})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return team_id, None
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return None, f"Failed to query Linear API: {str(e)}"
|
|
97
|
+
|
|
98
|
+
|
|
16
99
|
def _create_linear_client() -> Client:
|
|
17
100
|
"""Create a Linear GraphQL client."""
|
|
18
101
|
api_key = os.getenv("LINEAR_API_KEY")
|
|
19
102
|
if not api_key:
|
|
20
103
|
console.print("[red]❌ LINEAR_API_KEY not found in environment[/red]")
|
|
21
104
|
console.print("Set it in .env.local or environment variables")
|
|
22
|
-
raise typer.Exit(1)
|
|
105
|
+
raise typer.Exit(1) from None
|
|
23
106
|
|
|
24
107
|
transport = HTTPXTransport(
|
|
25
108
|
url="https://api.linear.app/graphql", headers={"Authorization": api_key}
|
|
@@ -28,7 +111,7 @@ def _create_linear_client() -> Client:
|
|
|
28
111
|
|
|
29
112
|
|
|
30
113
|
@app.command("workspaces")
|
|
31
|
-
def list_workspaces():
|
|
114
|
+
def list_workspaces() -> None:
|
|
32
115
|
"""List all accessible Linear workspaces."""
|
|
33
116
|
console.print("🔍 Discovering Linear workspaces...")
|
|
34
117
|
|
|
@@ -75,18 +158,18 @@ def list_workspaces():
|
|
|
75
158
|
|
|
76
159
|
except Exception as e:
|
|
77
160
|
console.print(f"[red]❌ Error fetching workspace info: {e}[/red]")
|
|
78
|
-
raise typer.Exit(1)
|
|
161
|
+
raise typer.Exit(1) from e
|
|
79
162
|
|
|
80
163
|
|
|
81
164
|
@app.command("teams")
|
|
82
165
|
def list_teams(
|
|
83
|
-
workspace:
|
|
166
|
+
workspace: str | None = typer.Option(
|
|
84
167
|
None, "--workspace", "-w", help="Workspace URL key (optional)"
|
|
85
168
|
),
|
|
86
169
|
all_teams: bool = typer.Option(
|
|
87
170
|
False, "--all", "-a", help="Show all teams across all workspaces"
|
|
88
171
|
),
|
|
89
|
-
):
|
|
172
|
+
) -> None:
|
|
90
173
|
"""List all teams in the current workspace or all accessible teams."""
|
|
91
174
|
if all_teams:
|
|
92
175
|
console.print("🔍 Discovering ALL accessible Linear teams across workspaces...")
|
|
@@ -245,25 +328,25 @@ def list_teams(
|
|
|
245
328
|
|
|
246
329
|
except Exception as e:
|
|
247
330
|
console.print(f"[red]❌ Error fetching teams: {e}[/red]")
|
|
248
|
-
raise typer.Exit(1)
|
|
331
|
+
raise typer.Exit(1) from e
|
|
249
332
|
|
|
250
333
|
|
|
251
334
|
@app.command("configure")
|
|
252
335
|
def configure_team(
|
|
253
|
-
team_key:
|
|
336
|
+
team_key: str | None = typer.Option(
|
|
254
337
|
None, "--team-key", "-k", help="Team key (e.g., '1M')"
|
|
255
338
|
),
|
|
256
|
-
team_id:
|
|
257
|
-
workspace:
|
|
339
|
+
team_id: str | None = typer.Option(None, "--team-id", "-i", help="Team UUID"),
|
|
340
|
+
workspace: str | None = typer.Option(
|
|
258
341
|
None, "--workspace", "-w", help="Workspace URL key"
|
|
259
342
|
),
|
|
260
|
-
):
|
|
343
|
+
) -> None:
|
|
261
344
|
"""Configure Linear adapter with a specific team."""
|
|
262
345
|
from ..cli.main import load_config, save_config
|
|
263
346
|
|
|
264
347
|
if not team_key and not team_id:
|
|
265
348
|
console.print("[red]❌ Either --team-key or --team-id is required[/red]")
|
|
266
|
-
raise typer.Exit(1)
|
|
349
|
+
raise typer.Exit(1) from None
|
|
267
350
|
|
|
268
351
|
console.print("🔧 Configuring Linear adapter...")
|
|
269
352
|
|
|
@@ -293,11 +376,11 @@ def configure_team(
|
|
|
293
376
|
|
|
294
377
|
if not team:
|
|
295
378
|
console.print(f"[red]❌ Team with ID '{team_id}' not found[/red]")
|
|
296
|
-
raise typer.Exit(1)
|
|
379
|
+
raise typer.Exit(1) from None
|
|
297
380
|
|
|
298
381
|
except Exception as e:
|
|
299
382
|
console.print(f"[red]❌ Error validating team: {e}[/red]")
|
|
300
|
-
raise typer.Exit(1)
|
|
383
|
+
raise typer.Exit(1) from e
|
|
301
384
|
|
|
302
385
|
elif team_key:
|
|
303
386
|
# Validate team by key
|
|
@@ -326,14 +409,14 @@ def configure_team(
|
|
|
326
409
|
|
|
327
410
|
if not teams:
|
|
328
411
|
console.print(f"[red]❌ Team with key '{team_key}' not found[/red]")
|
|
329
|
-
raise typer.Exit(1)
|
|
412
|
+
raise typer.Exit(1) from None
|
|
330
413
|
|
|
331
414
|
team = teams[0]
|
|
332
415
|
team_id = team["id"] # Use the found team ID
|
|
333
416
|
|
|
334
417
|
except Exception as e:
|
|
335
418
|
console.print(f"[red]❌ Error validating team: {e}[/red]")
|
|
336
|
-
raise typer.Exit(1)
|
|
419
|
+
raise typer.Exit(1) from e
|
|
337
420
|
|
|
338
421
|
# Update configuration
|
|
339
422
|
config = load_config()
|
|
@@ -382,17 +465,17 @@ def configure_team(
|
|
|
382
465
|
|
|
383
466
|
@app.command("info")
|
|
384
467
|
def show_info(
|
|
385
|
-
team_key:
|
|
468
|
+
team_key: str | None = typer.Option(
|
|
386
469
|
None, "--team-key", "-k", help="Team key to show info for"
|
|
387
470
|
),
|
|
388
|
-
team_id:
|
|
471
|
+
team_id: str | None = typer.Option(
|
|
389
472
|
None, "--team-id", "-i", help="Team UUID to show info for"
|
|
390
473
|
),
|
|
391
|
-
):
|
|
474
|
+
) -> None:
|
|
392
475
|
"""Show detailed information about a specific team."""
|
|
393
476
|
if not team_key and not team_id:
|
|
394
477
|
console.print("[red]❌ Either --team-key or --team-id is required[/red]")
|
|
395
|
-
raise typer.Exit(1)
|
|
478
|
+
raise typer.Exit(1) from None
|
|
396
479
|
|
|
397
480
|
# Query for detailed team information
|
|
398
481
|
if team_id:
|
|
@@ -483,7 +566,7 @@ def show_info(
|
|
|
483
566
|
if not team:
|
|
484
567
|
identifier = team_id or team_key
|
|
485
568
|
console.print(f"[red]❌ Team '{identifier}' not found[/red]")
|
|
486
|
-
raise typer.Exit(1)
|
|
569
|
+
raise typer.Exit(1) from None
|
|
487
570
|
|
|
488
571
|
# Display team information
|
|
489
572
|
console.print(f"\n🏷️ Team: {team.get('name')}")
|
|
@@ -530,4 +613,4 @@ def show_info(
|
|
|
530
613
|
|
|
531
614
|
except Exception as e:
|
|
532
615
|
console.print(f"[red]❌ Error fetching team info: {e}[/red]")
|
|
533
|
-
raise typer.Exit(1)
|
|
616
|
+
raise typer.Exit(1) from e
|