janet-cli 0.2.8__py3-none-any.whl → 0.2.33__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.
- janet/__init__.py +1 -1
- janet/api/client.py +36 -0
- janet/api/projects.py +20 -0
- janet/api/tickets.py +144 -1
- janet/auth/callback_server.py +5 -4
- janet/auth/oauth_flow.py +0 -1
- janet/auth/token_manager.py +31 -5
- janet/cli.py +512 -10
- janet/config/models.py +13 -1
- janet/markdown/generator.py +74 -21
- janet/markdown/yjs_converter.py +103 -17
- janet/sync/readme_generator.py +186 -90
- janet/sync/sse_watcher.py +264 -0
- janet/sync/sync_engine.py +14 -5
- janet_cli-0.2.33.dist-info/METADATA +356 -0
- janet_cli-0.2.33.dist-info/RECORD +34 -0
- janet_cli-0.2.8.dist-info/METADATA +0 -215
- janet_cli-0.2.8.dist-info/RECORD +0 -33
- {janet_cli-0.2.8.dist-info → janet_cli-0.2.33.dist-info}/WHEEL +0 -0
- {janet_cli-0.2.8.dist-info → janet_cli-0.2.33.dist-info}/entry_points.txt +0 -0
- {janet_cli-0.2.8.dist-info → janet_cli-0.2.33.dist-info}/licenses/LICENSE +0 -0
- {janet_cli-0.2.8.dist-info → janet_cli-0.2.33.dist-info}/top_level.txt +0 -0
janet/cli.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Main CLI application using Typer."""
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
3
5
|
import typer
|
|
6
|
+
from typing import Optional, List
|
|
4
7
|
from typing_extensions import Annotated
|
|
5
8
|
|
|
6
9
|
from janet import __version__
|
|
@@ -20,11 +23,13 @@ auth_app = typer.Typer(help="Authentication commands")
|
|
|
20
23
|
org_app = typer.Typer(help="Organization management")
|
|
21
24
|
project_app = typer.Typer(help="Project management")
|
|
22
25
|
config_app = typer.Typer(help="Configuration management")
|
|
26
|
+
ticket_app = typer.Typer(help="Ticket management")
|
|
23
27
|
|
|
24
|
-
app.add_typer(auth_app, name="auth")
|
|
25
|
-
app.add_typer(org_app, name="org")
|
|
26
|
-
app.add_typer(project_app, name="project")
|
|
27
|
-
app.add_typer(config_app, name="config")
|
|
28
|
+
app.add_typer(auth_app, name="auth", rich_help_panel="Management")
|
|
29
|
+
app.add_typer(org_app, name="org", rich_help_panel="Management")
|
|
30
|
+
app.add_typer(project_app, name="project", rich_help_panel="Management")
|
|
31
|
+
app.add_typer(config_app, name="config", rich_help_panel="Management")
|
|
32
|
+
app.add_typer(ticket_app, name="ticket", rich_help_panel="Management")
|
|
28
33
|
|
|
29
34
|
# Initialize config manager
|
|
30
35
|
config_manager = ConfigManager()
|
|
@@ -52,7 +57,7 @@ def main(
|
|
|
52
57
|
# =============================================================================
|
|
53
58
|
|
|
54
59
|
|
|
55
|
-
@app.command(name="login")
|
|
60
|
+
@app.command(name="login", rich_help_panel="Authentication")
|
|
56
61
|
def login() -> None:
|
|
57
62
|
"""Authenticate with Janet AI and select organization."""
|
|
58
63
|
try:
|
|
@@ -106,14 +111,75 @@ def login() -> None:
|
|
|
106
111
|
|
|
107
112
|
print_success(f"Selected organization: {selected_org['name']}")
|
|
108
113
|
console.print("\n[green]✓ Authentication complete![/green]")
|
|
109
|
-
console.print("Run
|
|
114
|
+
console.print("Run [cyan]janet sync[/cyan] to sync tickets and watch for real-time updates.")
|
|
110
115
|
|
|
111
116
|
except JanetCLIError as e:
|
|
112
117
|
print_error(str(e))
|
|
113
118
|
raise typer.Exit(1)
|
|
114
119
|
|
|
115
120
|
|
|
116
|
-
@app.command(name="
|
|
121
|
+
@app.command(name="update", rich_help_panel="Utilities")
|
|
122
|
+
def update(
|
|
123
|
+
test_pypi: bool = typer.Option(False, "--test", help="Update from Test PyPI (for development)")
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Update Janet CLI to the latest version."""
|
|
126
|
+
import subprocess
|
|
127
|
+
import httpx
|
|
128
|
+
from janet import __version__
|
|
129
|
+
|
|
130
|
+
console.print("[cyan]Checking for updates...[/cyan]")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Determine PyPI URL based on flag
|
|
134
|
+
if test_pypi:
|
|
135
|
+
pypi_url = "https://test.pypi.org/pypi/janet-cli/json"
|
|
136
|
+
index_url = "https://test.pypi.org/simple/"
|
|
137
|
+
else:
|
|
138
|
+
pypi_url = "https://pypi.org/pypi/janet-cli/json"
|
|
139
|
+
index_url = None
|
|
140
|
+
|
|
141
|
+
# Fetch latest version from PyPI
|
|
142
|
+
try:
|
|
143
|
+
response = httpx.get(pypi_url, timeout=10)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
latest_version = response.json()["info"]["version"]
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print_error(f"Failed to check for updates: {e}")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
current_version = __version__
|
|
151
|
+
|
|
152
|
+
console.print(f"[dim]Current version: {current_version}[/dim]")
|
|
153
|
+
console.print(f"[dim]Latest version: {latest_version}[/dim]")
|
|
154
|
+
|
|
155
|
+
# Compare versions
|
|
156
|
+
if current_version == latest_version:
|
|
157
|
+
console.print("[green]Janet CLI is already up to date.[/green]")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Build pip command
|
|
161
|
+
pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "janet-cli"]
|
|
162
|
+
if index_url:
|
|
163
|
+
pip_cmd.extend(["--index-url", index_url])
|
|
164
|
+
|
|
165
|
+
console.print(f"[cyan]Updating to version {latest_version}...[/cyan]")
|
|
166
|
+
|
|
167
|
+
result = subprocess.run(pip_cmd, capture_output=True, text=True)
|
|
168
|
+
|
|
169
|
+
if result.returncode == 0:
|
|
170
|
+
console.print(f"[green]✓ Janet CLI updated to {latest_version}![/green]")
|
|
171
|
+
console.print("[dim]Restart your terminal to use the new version.[/dim]")
|
|
172
|
+
else:
|
|
173
|
+
print_error(f"Update failed: {result.stderr}")
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
except typer.Exit:
|
|
176
|
+
raise
|
|
177
|
+
except Exception as e:
|
|
178
|
+
print_error(f"Update failed: {e}")
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command(name="logout", rich_help_panel="Authentication")
|
|
117
183
|
def logout() -> None:
|
|
118
184
|
"""Clear stored credentials."""
|
|
119
185
|
try:
|
|
@@ -227,9 +293,35 @@ def org_select(org_id: str) -> None:
|
|
|
227
293
|
|
|
228
294
|
# Update config
|
|
229
295
|
config = config_manager.get()
|
|
296
|
+
old_org_id = config.selected_organization.id if config.selected_organization else None
|
|
297
|
+
|
|
230
298
|
config.selected_organization = OrganizationInfo(
|
|
231
299
|
id=org_data["id"], name=org_data["name"], uuid=org_data.get("uuid", org_id)
|
|
232
300
|
)
|
|
301
|
+
|
|
302
|
+
# Clear synced projects when switching orgs (they belong to the old org)
|
|
303
|
+
if old_org_id and old_org_id != org_data["id"]:
|
|
304
|
+
config.sync.synced_projects = []
|
|
305
|
+
config.sync.last_sync_org_id = None
|
|
306
|
+
config.sync.last_sync_total_tickets = 0
|
|
307
|
+
|
|
308
|
+
# Regenerate README with new org but empty projects (no statuses until sync)
|
|
309
|
+
from janet.utils.paths import expand_path
|
|
310
|
+
from pathlib import Path
|
|
311
|
+
|
|
312
|
+
sync_dir = expand_path(config.sync.root_directory)
|
|
313
|
+
if sync_dir.exists():
|
|
314
|
+
from janet.sync.readme_generator import ReadmeGenerator
|
|
315
|
+
readme_gen = ReadmeGenerator()
|
|
316
|
+
readme_gen.write_readme(
|
|
317
|
+
sync_dir=sync_dir,
|
|
318
|
+
org_name=org_data["name"],
|
|
319
|
+
projects=[],
|
|
320
|
+
total_tickets=0,
|
|
321
|
+
project_statuses={},
|
|
322
|
+
)
|
|
323
|
+
print_info(f"README updated for new organization. Run 'janet sync' to sync projects.")
|
|
324
|
+
|
|
233
325
|
config_manager.update(config)
|
|
234
326
|
|
|
235
327
|
print_success(f"Selected organization: {org_data['name']}")
|
|
@@ -313,15 +405,17 @@ def project_list() -> None:
|
|
|
313
405
|
# =============================================================================
|
|
314
406
|
|
|
315
407
|
|
|
316
|
-
@app.command(name="sync")
|
|
408
|
+
@app.command(name="sync", rich_help_panel="Syncing")
|
|
317
409
|
def sync(
|
|
318
410
|
directory: Annotated[str, typer.Option("--dir", "-d", help="Sync directory")] = None,
|
|
319
411
|
all_projects: Annotated[bool, typer.Option("--all", help="Sync all projects")] = False,
|
|
412
|
+
no_watch: Annotated[bool, typer.Option("--no-watch", help="Exit after sync instead of watching for updates")] = False,
|
|
320
413
|
) -> None:
|
|
321
414
|
"""
|
|
322
|
-
Sync tickets to local markdown files.
|
|
415
|
+
Sync tickets to local markdown files and watch for real-time updates.
|
|
323
416
|
|
|
324
417
|
Interactive mode: prompts for project selection and directory.
|
|
418
|
+
After syncing, stays connected for real-time updates (Ctrl+C to stop).
|
|
325
419
|
"""
|
|
326
420
|
try:
|
|
327
421
|
from janet.sync.sync_engine import SyncEngine
|
|
@@ -489,6 +583,20 @@ def sync(
|
|
|
489
583
|
synced = sync_engine.sync_project(project["id"], project_key, project_name)
|
|
490
584
|
total_tickets += synced
|
|
491
585
|
|
|
586
|
+
# Fetch project statuses (kanban columns) for each project
|
|
587
|
+
project_statuses = {}
|
|
588
|
+
try:
|
|
589
|
+
for project in selected_projects:
|
|
590
|
+
project_id = project.get("id", "")
|
|
591
|
+
project_key = project.get("project_identifier", "")
|
|
592
|
+
if project_id:
|
|
593
|
+
columns = project_api.get_project_columns(project_id)
|
|
594
|
+
# Extract status values in order
|
|
595
|
+
statuses = [col.get("status_value", "") for col in sorted(columns, key=lambda x: x.get("column_order", 0))]
|
|
596
|
+
project_statuses[project_key] = statuses
|
|
597
|
+
except Exception as e:
|
|
598
|
+
print_info(f"Note: Could not fetch project statuses: {e}")
|
|
599
|
+
|
|
492
600
|
# Generate README for AI agents
|
|
493
601
|
from janet.sync.readme_generator import ReadmeGenerator
|
|
494
602
|
readme_gen = ReadmeGenerator()
|
|
@@ -497,8 +605,24 @@ def sync(
|
|
|
497
605
|
org_name=org_name,
|
|
498
606
|
projects=selected_projects,
|
|
499
607
|
total_tickets=total_tickets,
|
|
608
|
+
project_statuses=project_statuses,
|
|
500
609
|
)
|
|
501
610
|
|
|
611
|
+
# Save synced projects to config for README regeneration on org change
|
|
612
|
+
from janet.config.models import SyncedProject
|
|
613
|
+
config.sync.synced_projects = [
|
|
614
|
+
SyncedProject(
|
|
615
|
+
id=p.get("id", ""),
|
|
616
|
+
project_identifier=p.get("project_identifier", ""),
|
|
617
|
+
project_name=p.get("project_name", ""),
|
|
618
|
+
ticket_count=p.get("ticket_count", 0),
|
|
619
|
+
)
|
|
620
|
+
for p in selected_projects
|
|
621
|
+
]
|
|
622
|
+
config.sync.last_sync_org_id = config.selected_organization.id
|
|
623
|
+
config.sync.last_sync_total_tickets = total_tickets
|
|
624
|
+
config_manager.update(config)
|
|
625
|
+
|
|
502
626
|
# Show summary
|
|
503
627
|
console.print(f"\n[bold green]✓ Sync complete![/bold green]")
|
|
504
628
|
console.print(f" Projects: {len(selected_projects)}")
|
|
@@ -506,6 +630,36 @@ def sync(
|
|
|
506
630
|
console.print(f"\n[cyan]Tickets saved to: {expanded_dir}[/cyan]")
|
|
507
631
|
console.print(f"[dim]README for AI agents: {readme_path}[/dim]")
|
|
508
632
|
|
|
633
|
+
# Start watch mode (default behavior, unless --no-watch)
|
|
634
|
+
if not no_watch:
|
|
635
|
+
from janet.sync.sse_watcher import SSEWatcher
|
|
636
|
+
from janet.api.organizations import OrganizationAPI
|
|
637
|
+
|
|
638
|
+
console.print(f"\n")
|
|
639
|
+
|
|
640
|
+
# Fetch org members for name resolution in SSE updates
|
|
641
|
+
org_members = None
|
|
642
|
+
try:
|
|
643
|
+
org_api = OrganizationAPI(config_manager)
|
|
644
|
+
org_id = config.selected_organization.id
|
|
645
|
+
response = org_api.get(f"/api/v1/organizations/{org_id}/members", include_org=False)
|
|
646
|
+
org_members = response.get("members", [])
|
|
647
|
+
except Exception:
|
|
648
|
+
pass # Will fall back to emails if members can't be fetched
|
|
649
|
+
|
|
650
|
+
# Create SSE watcher
|
|
651
|
+
watcher = SSEWatcher(
|
|
652
|
+
config_manager=config_manager,
|
|
653
|
+
projects=selected_projects,
|
|
654
|
+
org_name=org_name,
|
|
655
|
+
sync_dir=str(expanded_dir),
|
|
656
|
+
org_members=org_members,
|
|
657
|
+
project_statuses=project_statuses,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# This blocks until Ctrl+C
|
|
661
|
+
watcher.watch()
|
|
662
|
+
|
|
509
663
|
except JanetCLIError as e:
|
|
510
664
|
print_error(str(e))
|
|
511
665
|
raise typer.Exit(1)
|
|
@@ -519,7 +673,7 @@ def sync(
|
|
|
519
673
|
# =============================================================================
|
|
520
674
|
|
|
521
675
|
|
|
522
|
-
@app.command(name="status")
|
|
676
|
+
@app.command(name="status", rich_help_panel="Syncing")
|
|
523
677
|
def status() -> None:
|
|
524
678
|
"""Show overall status (auth, org, last sync)."""
|
|
525
679
|
try:
|
|
@@ -598,5 +752,353 @@ def config_reset(
|
|
|
598
752
|
raise typer.Exit(1)
|
|
599
753
|
|
|
600
754
|
|
|
755
|
+
# =============================================================================
|
|
756
|
+
# Ticket Commands
|
|
757
|
+
# =============================================================================
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@ticket_app.command(name="create")
|
|
761
|
+
def ticket_create(
|
|
762
|
+
title: Annotated[str, typer.Argument(help="Ticket title")],
|
|
763
|
+
project: Annotated[Optional[str], typer.Option("--project", "-p", help="Project key (e.g., PROJ) or ID")] = None,
|
|
764
|
+
description: Annotated[Optional[str], typer.Option("--description", "-d", help="Ticket description")] = None,
|
|
765
|
+
status: Annotated[Optional[str], typer.Option("--status", "-s", help="Status (default: To Do)")] = None,
|
|
766
|
+
priority: Annotated[Optional[str], typer.Option("--priority", help="Priority: Low, Medium, High, Critical")] = None,
|
|
767
|
+
issue_type: Annotated[Optional[str], typer.Option("--type", "-t", help="Type: Task, Bug, Story, Epic")] = None,
|
|
768
|
+
assignee: Annotated[Optional[List[str]], typer.Option("--assignee", "-a", help="Assignee email (can repeat)")] = None,
|
|
769
|
+
tag: Annotated[Optional[List[str]], typer.Option("--tag", help="Tag (can repeat)")] = None,
|
|
770
|
+
output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
771
|
+
) -> None:
|
|
772
|
+
"""
|
|
773
|
+
Create a new ticket.
|
|
774
|
+
|
|
775
|
+
Examples:
|
|
776
|
+
janet ticket create "Fix login bug" -p PROJ
|
|
777
|
+
janet ticket create "Add feature" -p PROJ -d "Details here" --priority High
|
|
778
|
+
echo "Description" | janet ticket create "Title" -p PROJ
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
from janet.api.tickets import TicketAPI
|
|
782
|
+
from janet.api.projects import ProjectAPI
|
|
783
|
+
|
|
784
|
+
if not config_manager.is_authenticated():
|
|
785
|
+
print_error("Not authenticated. Run 'janet login' first.")
|
|
786
|
+
raise typer.Exit(1)
|
|
787
|
+
|
|
788
|
+
if not config_manager.has_organization():
|
|
789
|
+
print_error("No organization selected. Run 'janet org select' first.")
|
|
790
|
+
raise typer.Exit(1)
|
|
791
|
+
|
|
792
|
+
# Get project list
|
|
793
|
+
project_api = ProjectAPI(config_manager)
|
|
794
|
+
projects = project_api.list_projects()
|
|
795
|
+
|
|
796
|
+
if not projects:
|
|
797
|
+
print_error("No projects found in organization")
|
|
798
|
+
raise typer.Exit(1)
|
|
799
|
+
|
|
800
|
+
# Resolve project
|
|
801
|
+
project_id = None
|
|
802
|
+
project_key = None
|
|
803
|
+
|
|
804
|
+
if project:
|
|
805
|
+
# Try to match by key or ID
|
|
806
|
+
for p in projects:
|
|
807
|
+
if p.get("project_identifier", "").upper() == project.upper():
|
|
808
|
+
project_id = p["id"]
|
|
809
|
+
project_key = p["project_identifier"]
|
|
810
|
+
break
|
|
811
|
+
if p.get("id") == project:
|
|
812
|
+
project_id = p["id"]
|
|
813
|
+
project_key = p.get("project_identifier", "")
|
|
814
|
+
break
|
|
815
|
+
|
|
816
|
+
if not project_id:
|
|
817
|
+
print_error(f"Project '{project}' not found")
|
|
818
|
+
raise typer.Exit(1)
|
|
819
|
+
else:
|
|
820
|
+
# Interactive project selection
|
|
821
|
+
if not sys.stdin.isatty():
|
|
822
|
+
print_error("--project is required for non-interactive use")
|
|
823
|
+
raise typer.Exit(1)
|
|
824
|
+
|
|
825
|
+
from InquirerPy import inquirer
|
|
826
|
+
|
|
827
|
+
console.print("\n[bold]Select a project:[/bold]\n")
|
|
828
|
+
|
|
829
|
+
choices = []
|
|
830
|
+
for p in projects:
|
|
831
|
+
key = p.get("project_identifier", "")
|
|
832
|
+
name = p.get("project_name", "")
|
|
833
|
+
count = p.get("ticket_count", 0)
|
|
834
|
+
label_text = f"{key:8s} - {name} ({count} tickets)"
|
|
835
|
+
choices.append({"name": label_text, "value": p})
|
|
836
|
+
|
|
837
|
+
selected = inquirer.select(
|
|
838
|
+
message="Project:",
|
|
839
|
+
choices=choices,
|
|
840
|
+
).execute()
|
|
841
|
+
|
|
842
|
+
project_id = selected["id"]
|
|
843
|
+
project_key = selected.get("project_identifier", "")
|
|
844
|
+
|
|
845
|
+
# Check for piped stdin for description
|
|
846
|
+
final_description = description
|
|
847
|
+
if not sys.stdin.isatty() and not description:
|
|
848
|
+
# Read from stdin
|
|
849
|
+
stdin_content = sys.stdin.read().strip()
|
|
850
|
+
if stdin_content:
|
|
851
|
+
final_description = stdin_content
|
|
852
|
+
|
|
853
|
+
# Create ticket
|
|
854
|
+
ticket_api = TicketAPI(config_manager)
|
|
855
|
+
result = ticket_api.create_ticket(
|
|
856
|
+
project_id=project_id,
|
|
857
|
+
title=title,
|
|
858
|
+
description=final_description,
|
|
859
|
+
status=status or "To Do",
|
|
860
|
+
priority=priority,
|
|
861
|
+
issue_type=issue_type,
|
|
862
|
+
assignees=assignee,
|
|
863
|
+
labels=tag,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
ticket_key_result = result.get("ticket_key", f"{project_key}-{result.get('ticket_identifier', '?')}")
|
|
867
|
+
|
|
868
|
+
if output_json:
|
|
869
|
+
output = {
|
|
870
|
+
"success": True,
|
|
871
|
+
"ticket_id": result.get("ticket_id"),
|
|
872
|
+
"ticket_key": ticket_key_result,
|
|
873
|
+
"title": title,
|
|
874
|
+
"project_key": project_key,
|
|
875
|
+
}
|
|
876
|
+
console.print(json.dumps(output, indent=2))
|
|
877
|
+
else:
|
|
878
|
+
print_success(f"Created {ticket_key_result}: {title}")
|
|
879
|
+
|
|
880
|
+
except JanetCLIError as e:
|
|
881
|
+
if output_json:
|
|
882
|
+
console.print(json.dumps({"success": False, "error": str(e)}))
|
|
883
|
+
else:
|
|
884
|
+
print_error(str(e))
|
|
885
|
+
raise typer.Exit(1)
|
|
886
|
+
except Exception as e:
|
|
887
|
+
if output_json:
|
|
888
|
+
console.print(json.dumps({"success": False, "error": str(e)}))
|
|
889
|
+
else:
|
|
890
|
+
print_error(f"Failed to create ticket: {e}")
|
|
891
|
+
raise typer.Exit(1)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
@ticket_app.command(name="update")
|
|
895
|
+
def ticket_update(
|
|
896
|
+
ticket_key: Annotated[str, typer.Argument(help="Ticket key (e.g., PROJ-123) or ticket ID")],
|
|
897
|
+
title: Annotated[Optional[str], typer.Option("--title", help="New title")] = None,
|
|
898
|
+
description: Annotated[Optional[str], typer.Option("--description", "-d", help="New description")] = None,
|
|
899
|
+
status: Annotated[Optional[str], typer.Option("--status", "-s", help="New status")] = None,
|
|
900
|
+
priority: Annotated[Optional[str], typer.Option("--priority", help="New priority: Low, Medium, High, Critical")] = None,
|
|
901
|
+
issue_type: Annotated[Optional[str], typer.Option("--type", "-t", help="New type: Task, Bug, Story, Epic")] = None,
|
|
902
|
+
assignee: Annotated[Optional[List[str]], typer.Option("--assignee", "-a", help="New assignee email (can repeat, replaces all)")] = None,
|
|
903
|
+
tag: Annotated[Optional[List[str]], typer.Option("--tag", help="New tag (can repeat, replaces all)")] = None,
|
|
904
|
+
due_date: Annotated[Optional[str], typer.Option("--due-date", help="New due date (YYYY-MM-DD)")] = None,
|
|
905
|
+
output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
906
|
+
) -> None:
|
|
907
|
+
"""
|
|
908
|
+
Update an existing ticket.
|
|
909
|
+
|
|
910
|
+
Examples:
|
|
911
|
+
janet ticket update PROJ-123 --status "In Progress"
|
|
912
|
+
janet ticket update PROJ-123 --title "New title" --priority High
|
|
913
|
+
janet ticket update PROJ-123 -a user@example.com -a user2@example.com
|
|
914
|
+
"""
|
|
915
|
+
try:
|
|
916
|
+
from janet.api.tickets import TicketAPI
|
|
917
|
+
from janet.api.projects import ProjectAPI
|
|
918
|
+
|
|
919
|
+
if not config_manager.is_authenticated():
|
|
920
|
+
print_error("Not authenticated. Run 'janet login' first.")
|
|
921
|
+
raise typer.Exit(1)
|
|
922
|
+
|
|
923
|
+
if not config_manager.has_organization():
|
|
924
|
+
print_error("No organization selected. Run 'janet org select' first.")
|
|
925
|
+
raise typer.Exit(1)
|
|
926
|
+
|
|
927
|
+
# Resolve ticket_key to ticket_id
|
|
928
|
+
ticket_id = None
|
|
929
|
+
resolved_ticket_key = ticket_key
|
|
930
|
+
|
|
931
|
+
# Check if it looks like a UUID (ticket ID)
|
|
932
|
+
if "-" in ticket_key and len(ticket_key) == 36:
|
|
933
|
+
ticket_id = ticket_key
|
|
934
|
+
else:
|
|
935
|
+
# It's a ticket key like PROJ-123, need to find the ticket ID
|
|
936
|
+
# Parse project key and ticket number
|
|
937
|
+
parts = ticket_key.upper().rsplit("-", 1)
|
|
938
|
+
if len(parts) != 2:
|
|
939
|
+
print_error(f"Invalid ticket key format: {ticket_key}. Expected format: PROJ-123")
|
|
940
|
+
raise typer.Exit(1)
|
|
941
|
+
|
|
942
|
+
project_key, ticket_num = parts
|
|
943
|
+
|
|
944
|
+
# Get project list to find the project
|
|
945
|
+
project_api = ProjectAPI(config_manager)
|
|
946
|
+
projects = project_api.list_projects()
|
|
947
|
+
|
|
948
|
+
project_id = None
|
|
949
|
+
for p in projects:
|
|
950
|
+
if p.get("project_identifier", "").upper() == project_key:
|
|
951
|
+
project_id = p["id"]
|
|
952
|
+
break
|
|
953
|
+
|
|
954
|
+
if not project_id:
|
|
955
|
+
print_error(f"Project '{project_key}' not found")
|
|
956
|
+
raise typer.Exit(1)
|
|
957
|
+
|
|
958
|
+
# Fetch tickets from project to find the one with matching identifier
|
|
959
|
+
ticket_api = TicketAPI(config_manager)
|
|
960
|
+
sync_result = ticket_api.sync_all_tickets(project_id)
|
|
961
|
+
tickets = sync_result.get("tickets", [])
|
|
962
|
+
|
|
963
|
+
for t in tickets:
|
|
964
|
+
if str(t.get("ticket_identifier")) == ticket_num:
|
|
965
|
+
ticket_id = t["id"]
|
|
966
|
+
break
|
|
967
|
+
|
|
968
|
+
if not ticket_id:
|
|
969
|
+
print_error(f"Ticket '{ticket_key}' not found")
|
|
970
|
+
raise typer.Exit(1)
|
|
971
|
+
|
|
972
|
+
# Check if any update field was provided
|
|
973
|
+
if not any([title, description, status, priority, issue_type, assignee, tag, due_date]):
|
|
974
|
+
print_error("No update fields provided. Use --help to see available options.")
|
|
975
|
+
raise typer.Exit(1)
|
|
976
|
+
|
|
977
|
+
# Update ticket
|
|
978
|
+
ticket_api = TicketAPI(config_manager)
|
|
979
|
+
result = ticket_api.update_ticket(
|
|
980
|
+
ticket_id=ticket_id,
|
|
981
|
+
title=title,
|
|
982
|
+
description=description,
|
|
983
|
+
status=status,
|
|
984
|
+
priority=priority,
|
|
985
|
+
issue_type=issue_type,
|
|
986
|
+
assignees=assignee,
|
|
987
|
+
labels=tag,
|
|
988
|
+
due_date=due_date,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
if output_json:
|
|
992
|
+
output = {
|
|
993
|
+
"success": True,
|
|
994
|
+
"ticket_key": resolved_ticket_key,
|
|
995
|
+
"ticket_id": ticket_id,
|
|
996
|
+
"updated_fields": result.get("updated_fields", []),
|
|
997
|
+
}
|
|
998
|
+
console.print(json.dumps(output, indent=2))
|
|
999
|
+
else:
|
|
1000
|
+
updated = result.get("updated_fields", [])
|
|
1001
|
+
if updated:
|
|
1002
|
+
print_success(f"Updated {resolved_ticket_key}: {', '.join(updated)}")
|
|
1003
|
+
else:
|
|
1004
|
+
print_success(f"Updated {resolved_ticket_key}")
|
|
1005
|
+
|
|
1006
|
+
except JanetCLIError as e:
|
|
1007
|
+
if output_json:
|
|
1008
|
+
console.print(json.dumps({"success": False, "error": str(e)}))
|
|
1009
|
+
else:
|
|
1010
|
+
print_error(str(e))
|
|
1011
|
+
raise typer.Exit(1)
|
|
1012
|
+
except Exception as e:
|
|
1013
|
+
if output_json:
|
|
1014
|
+
console.print(json.dumps({"success": False, "error": str(e)}))
|
|
1015
|
+
else:
|
|
1016
|
+
print_error(f"Failed to update ticket: {e}")
|
|
1017
|
+
raise typer.Exit(1)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
# =============================================================================
|
|
1021
|
+
# Context Command (for AI agents)
|
|
1022
|
+
# =============================================================================
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
@app.command(name="context", rich_help_panel="Syncing")
|
|
1026
|
+
def context(
|
|
1027
|
+
output_json: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""
|
|
1030
|
+
Show current context (org, projects) for AI agents.
|
|
1031
|
+
|
|
1032
|
+
Use --json for machine-readable output.
|
|
1033
|
+
"""
|
|
1034
|
+
try:
|
|
1035
|
+
from janet.api.projects import ProjectAPI
|
|
1036
|
+
|
|
1037
|
+
config = config_manager.get()
|
|
1038
|
+
|
|
1039
|
+
context_data = {
|
|
1040
|
+
"authenticated": config_manager.is_authenticated(),
|
|
1041
|
+
"user_email": config.auth.user_email if config.auth else None,
|
|
1042
|
+
"organization": None,
|
|
1043
|
+
"projects": [],
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if config_manager.is_authenticated() and config.selected_organization:
|
|
1047
|
+
context_data["organization"] = {
|
|
1048
|
+
"id": config.selected_organization.id,
|
|
1049
|
+
"name": config.selected_organization.name,
|
|
1050
|
+
"uuid": config.selected_organization.uuid,
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
# Fetch projects
|
|
1054
|
+
if config_manager.has_organization():
|
|
1055
|
+
try:
|
|
1056
|
+
project_api = ProjectAPI(config_manager)
|
|
1057
|
+
projects = project_api.list_projects()
|
|
1058
|
+
context_data["projects"] = [
|
|
1059
|
+
{
|
|
1060
|
+
"id": p.get("id"),
|
|
1061
|
+
"key": p.get("project_identifier"),
|
|
1062
|
+
"name": p.get("project_name"),
|
|
1063
|
+
"ticket_count": p.get("ticket_count", 0),
|
|
1064
|
+
}
|
|
1065
|
+
for p in projects
|
|
1066
|
+
]
|
|
1067
|
+
except Exception:
|
|
1068
|
+
pass # Projects fetch failed, leave empty
|
|
1069
|
+
|
|
1070
|
+
if output_json:
|
|
1071
|
+
console.print(json.dumps(context_data, indent=2))
|
|
1072
|
+
else:
|
|
1073
|
+
console.print("[bold]Janet CLI Context[/bold]\n")
|
|
1074
|
+
|
|
1075
|
+
if not context_data["authenticated"]:
|
|
1076
|
+
console.print("[yellow]Not authenticated[/yellow]")
|
|
1077
|
+
console.print("Run 'janet login' to authenticate")
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
console.print(f"[green]✓ Authenticated[/green] as {context_data['user_email']}")
|
|
1081
|
+
|
|
1082
|
+
if context_data["organization"]:
|
|
1083
|
+
console.print(f"[green]✓ Organization:[/green] {context_data['organization']['name']}")
|
|
1084
|
+
else:
|
|
1085
|
+
console.print("[yellow]No organization selected[/yellow]")
|
|
1086
|
+
return
|
|
1087
|
+
|
|
1088
|
+
if context_data["projects"]:
|
|
1089
|
+
console.print(f"\n[bold]Projects ({len(context_data['projects'])}):[/bold]")
|
|
1090
|
+
for p in context_data["projects"]:
|
|
1091
|
+
console.print(f" • {p['key']:8s} - {p['name']} ({p['ticket_count']} tickets)")
|
|
1092
|
+
else:
|
|
1093
|
+
console.print("\n[dim]No projects found[/dim]")
|
|
1094
|
+
|
|
1095
|
+
except JanetCLIError as e:
|
|
1096
|
+
if output_json:
|
|
1097
|
+
console.print(json.dumps({"authenticated": False, "error": str(e)}))
|
|
1098
|
+
else:
|
|
1099
|
+
print_error(str(e))
|
|
1100
|
+
raise typer.Exit(1)
|
|
1101
|
+
|
|
1102
|
+
|
|
601
1103
|
if __name__ == "__main__":
|
|
602
1104
|
app()
|
janet/config/models.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from typing import Dict, Optional
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
|
|
@@ -37,6 +37,15 @@ class APIConfig(BaseModel):
|
|
|
37
37
|
timeout: int = 30
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class SyncedProject(BaseModel):
|
|
41
|
+
"""Information about a synced project."""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
project_identifier: str
|
|
45
|
+
project_name: str
|
|
46
|
+
ticket_count: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
class SyncConfig(BaseModel):
|
|
41
50
|
"""Sync configuration."""
|
|
42
51
|
|
|
@@ -44,6 +53,9 @@ class SyncConfig(BaseModel):
|
|
|
44
53
|
last_sync_times: Dict[str, str] = Field(default_factory=dict)
|
|
45
54
|
sync_on_init: bool = False
|
|
46
55
|
batch_size: int = 50
|
|
56
|
+
synced_projects: List[SyncedProject] = Field(default_factory=list)
|
|
57
|
+
last_sync_org_id: Optional[str] = None
|
|
58
|
+
last_sync_total_tickets: int = 0
|
|
47
59
|
|
|
48
60
|
|
|
49
61
|
class MarkdownConfig(BaseModel):
|