janet-cli 0.2.2__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 +3 -0
- janet/__main__.py +6 -0
- janet/api/__init__.py +0 -0
- janet/api/client.py +128 -0
- janet/api/models.py +92 -0
- janet/api/organizations.py +57 -0
- janet/api/projects.py +57 -0
- janet/api/tickets.py +125 -0
- janet/auth/__init__.py +0 -0
- janet/auth/callback_server.py +360 -0
- janet/auth/oauth_flow.py +276 -0
- janet/auth/token_manager.py +92 -0
- janet/cli.py +602 -0
- janet/config/__init__.py +0 -0
- janet/config/manager.py +116 -0
- janet/config/models.py +66 -0
- janet/markdown/__init__.py +0 -0
- janet/markdown/generator.py +272 -0
- janet/markdown/yjs_converter.py +225 -0
- janet/sync/__init__.py +0 -0
- janet/sync/file_manager.py +199 -0
- janet/sync/readme_generator.py +174 -0
- janet/sync/sync_engine.py +271 -0
- janet/utils/__init__.py +0 -0
- janet/utils/console.py +39 -0
- janet/utils/errors.py +49 -0
- janet/utils/paths.py +66 -0
- janet_cli-0.2.2.dist-info/METADATA +220 -0
- janet_cli-0.2.2.dist-info/RECORD +33 -0
- janet_cli-0.2.2.dist-info/WHEEL +5 -0
- janet_cli-0.2.2.dist-info/entry_points.txt +2 -0
- janet_cli-0.2.2.dist-info/licenses/LICENSE +21 -0
- janet_cli-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""File manager for writing ticket markdown files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from janet.utils.paths import sanitize_filename, expand_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileManager:
|
|
10
|
+
"""Manages writing ticket markdown files to filesystem."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, root_directory: str = "~/janet-tickets"):
|
|
13
|
+
"""
|
|
14
|
+
Initialize file manager.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
root_directory: Root directory for ticket files
|
|
18
|
+
"""
|
|
19
|
+
self.root_directory = expand_path(root_directory)
|
|
20
|
+
|
|
21
|
+
def write_ticket(
|
|
22
|
+
self,
|
|
23
|
+
org_name: str,
|
|
24
|
+
project_name: str,
|
|
25
|
+
ticket_key: str,
|
|
26
|
+
content: str,
|
|
27
|
+
) -> Path:
|
|
28
|
+
"""
|
|
29
|
+
Write ticket markdown to file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
org_name: Organization name
|
|
33
|
+
project_name: Project name
|
|
34
|
+
ticket_key: Ticket key (e.g., "PROJ-1")
|
|
35
|
+
content: Markdown content
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to created file
|
|
39
|
+
"""
|
|
40
|
+
# Sanitize names for filesystem
|
|
41
|
+
safe_org = sanitize_filename(org_name)
|
|
42
|
+
safe_project = sanitize_filename(project_name)
|
|
43
|
+
safe_key = sanitize_filename(ticket_key)
|
|
44
|
+
|
|
45
|
+
# Create directory structure: root/org/project/
|
|
46
|
+
project_dir = self.root_directory / safe_org / safe_project
|
|
47
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
# Create file path: root/org/project/TICKET-KEY.md
|
|
50
|
+
file_path = project_dir / f"{safe_key}.md"
|
|
51
|
+
|
|
52
|
+
# Write markdown content
|
|
53
|
+
file_path.write_text(content, encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
return file_path
|
|
56
|
+
|
|
57
|
+
def get_ticket_path(
|
|
58
|
+
self, org_name: str, project_name: str, ticket_key: str
|
|
59
|
+
) -> Path:
|
|
60
|
+
"""
|
|
61
|
+
Get path for a ticket file (without writing).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
org_name: Organization name
|
|
65
|
+
project_name: Project name
|
|
66
|
+
ticket_key: Ticket key
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path to ticket file
|
|
70
|
+
"""
|
|
71
|
+
safe_org = sanitize_filename(org_name)
|
|
72
|
+
safe_project = sanitize_filename(project_name)
|
|
73
|
+
safe_key = sanitize_filename(ticket_key)
|
|
74
|
+
|
|
75
|
+
return self.root_directory / safe_org / safe_project / f"{safe_key}.md"
|
|
76
|
+
|
|
77
|
+
def ticket_exists(self, org_name: str, project_name: str, ticket_key: str) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Check if ticket file exists.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
org_name: Organization name
|
|
83
|
+
project_name: Project name
|
|
84
|
+
ticket_key: Ticket key
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if file exists
|
|
88
|
+
"""
|
|
89
|
+
file_path = self.get_ticket_path(org_name, project_name, ticket_key)
|
|
90
|
+
return file_path.exists()
|
|
91
|
+
|
|
92
|
+
def list_tickets(self, org_name: Optional[str] = None, project_name: Optional[str] = None) -> list[Path]:
|
|
93
|
+
"""
|
|
94
|
+
List all ticket markdown files.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
org_name: Optional organization name to filter
|
|
98
|
+
project_name: Optional project name to filter
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of ticket file paths
|
|
102
|
+
"""
|
|
103
|
+
if org_name and project_name:
|
|
104
|
+
# List tickets in specific project
|
|
105
|
+
safe_org = sanitize_filename(org_name)
|
|
106
|
+
safe_project = sanitize_filename(project_name)
|
|
107
|
+
search_path = self.root_directory / safe_org / safe_project
|
|
108
|
+
elif org_name:
|
|
109
|
+
# List tickets in organization
|
|
110
|
+
safe_org = sanitize_filename(org_name)
|
|
111
|
+
search_path = self.root_directory / safe_org
|
|
112
|
+
else:
|
|
113
|
+
# List all tickets
|
|
114
|
+
search_path = self.root_directory
|
|
115
|
+
|
|
116
|
+
if not search_path.exists():
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
# Find all .md files
|
|
120
|
+
return list(search_path.rglob("*.md"))
|
|
121
|
+
|
|
122
|
+
def archive_ticket(
|
|
123
|
+
self, org_name: str, project_name: str, ticket_key: str
|
|
124
|
+
) -> Optional[Path]:
|
|
125
|
+
"""
|
|
126
|
+
Move ticket to .archived folder.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
org_name: Organization name
|
|
130
|
+
project_name: Project name
|
|
131
|
+
ticket_key: Ticket key
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
New path if archived, None if file didn't exist
|
|
135
|
+
"""
|
|
136
|
+
file_path = self.get_ticket_path(org_name, project_name, ticket_key)
|
|
137
|
+
|
|
138
|
+
if not file_path.exists():
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# Create .archived directory
|
|
142
|
+
archived_dir = file_path.parent / ".archived"
|
|
143
|
+
archived_dir.mkdir(exist_ok=True)
|
|
144
|
+
|
|
145
|
+
# Move file
|
|
146
|
+
new_path = archived_dir / file_path.name
|
|
147
|
+
file_path.rename(new_path)
|
|
148
|
+
|
|
149
|
+
return new_path
|
|
150
|
+
|
|
151
|
+
def delete_ticket(self, org_name: str, project_name: str, ticket_key: str) -> bool:
|
|
152
|
+
"""
|
|
153
|
+
Delete ticket file.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
org_name: Organization name
|
|
157
|
+
project_name: Project name
|
|
158
|
+
ticket_key: Ticket key
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if deleted, False if didn't exist
|
|
162
|
+
"""
|
|
163
|
+
file_path = self.get_ticket_path(org_name, project_name, ticket_key)
|
|
164
|
+
|
|
165
|
+
if not file_path.exists():
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
file_path.unlink()
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
def get_project_directory(self, org_name: str, project_name: str) -> Path:
|
|
172
|
+
"""
|
|
173
|
+
Get project directory path.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
org_name: Organization name
|
|
177
|
+
project_name: Project name
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Path to project directory
|
|
181
|
+
"""
|
|
182
|
+
safe_org = sanitize_filename(org_name)
|
|
183
|
+
safe_project = sanitize_filename(project_name)
|
|
184
|
+
return self.root_directory / safe_org / safe_project
|
|
185
|
+
|
|
186
|
+
def ensure_project_directory(self, org_name: str, project_name: str) -> Path:
|
|
187
|
+
"""
|
|
188
|
+
Ensure project directory exists.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
org_name: Organization name
|
|
192
|
+
project_name: Project name
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Path to project directory
|
|
196
|
+
"""
|
|
197
|
+
project_dir = self.get_project_directory(org_name, project_name)
|
|
198
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
return project_dir
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Generate README for synced ticket directory."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReadmeGenerator:
|
|
9
|
+
"""Generate README.md for synced ticket directory."""
|
|
10
|
+
|
|
11
|
+
def generate(
|
|
12
|
+
self,
|
|
13
|
+
org_name: str,
|
|
14
|
+
projects: List[Dict],
|
|
15
|
+
total_tickets: int,
|
|
16
|
+
sync_time: datetime,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Generate README content for ticket directory.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
org_name: Organization name
|
|
23
|
+
projects: List of synced projects
|
|
24
|
+
total_tickets: Total number of tickets synced
|
|
25
|
+
sync_time: Timestamp of sync
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
README markdown content
|
|
29
|
+
"""
|
|
30
|
+
sections = []
|
|
31
|
+
|
|
32
|
+
# Header
|
|
33
|
+
sections.append(f"# Janet AI Tickets - {org_name}\n")
|
|
34
|
+
sections.append(
|
|
35
|
+
"This directory contains your Janet AI tickets synced as markdown files "
|
|
36
|
+
"for use with AI coding agents like Claude Code, Cursor, and GitHub Copilot.\n"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# What is this?
|
|
40
|
+
sections.append("## What is this?\n")
|
|
41
|
+
sections.append(
|
|
42
|
+
"These markdown files are a local mirror of your tickets from "
|
|
43
|
+
"[Janet AI](https://tryjanet.ai), an AI-native project management platform. "
|
|
44
|
+
"Each ticket has been exported as a markdown file containing:\n"
|
|
45
|
+
)
|
|
46
|
+
sections.append("- **Metadata** - Status, priority, assignees, dates, labels")
|
|
47
|
+
sections.append("- **Description** - Full ticket description")
|
|
48
|
+
sections.append("- **Comments** - All comments with timestamps")
|
|
49
|
+
sections.append("- **Attachments** - Attachment metadata and descriptions")
|
|
50
|
+
sections.append("- **Child Tasks** - Sub-tasks if applicable\n")
|
|
51
|
+
|
|
52
|
+
# Why is this here?
|
|
53
|
+
sections.append("## Why is this here?\n")
|
|
54
|
+
sections.append(
|
|
55
|
+
"AI coding assistants work best when they have full context about your project. "
|
|
56
|
+
"By having your tickets in your workspace, AI agents can:\n"
|
|
57
|
+
)
|
|
58
|
+
sections.append("- Reference specific tickets while writing code")
|
|
59
|
+
sections.append("- Understand requirements and acceptance criteria")
|
|
60
|
+
sections.append("- Answer questions about project priorities and status")
|
|
61
|
+
sections.append("- Suggest implementations based on ticket descriptions")
|
|
62
|
+
sections.append("- Link code changes to relevant tickets\n")
|
|
63
|
+
|
|
64
|
+
# Directory structure
|
|
65
|
+
sections.append("## Directory Structure\n")
|
|
66
|
+
sections.append("```")
|
|
67
|
+
sections.append(f"{org_name}/")
|
|
68
|
+
for project in projects[:10]: # Show first 10 projects
|
|
69
|
+
key = project.get("project_identifier", "")
|
|
70
|
+
name = project.get("project_name", "")
|
|
71
|
+
count = project.get("ticket_count", 0)
|
|
72
|
+
sections.append(f"├── {name}/")
|
|
73
|
+
sections.append(f"│ ├── {key}-1.md")
|
|
74
|
+
sections.append(f"│ ├── {key}-2.md")
|
|
75
|
+
sections.append(f"│ └── ... ({count} tickets)")
|
|
76
|
+
|
|
77
|
+
if len(projects) > 10:
|
|
78
|
+
sections.append(f"└── ... ({len(projects) - 10} more projects)")
|
|
79
|
+
sections.append("```\n")
|
|
80
|
+
|
|
81
|
+
# Sync info
|
|
82
|
+
sections.append("## Sync Information\n")
|
|
83
|
+
sections.append(f"- **Organization:** {org_name}")
|
|
84
|
+
sections.append(f"- **Projects Synced:** {len(projects)}")
|
|
85
|
+
sections.append(f"- **Total Tickets:** {total_tickets}")
|
|
86
|
+
sections.append(
|
|
87
|
+
f"- **Last Synced:** {sync_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Projects summary
|
|
91
|
+
if projects:
|
|
92
|
+
sections.append("### Projects\n")
|
|
93
|
+
for project in projects:
|
|
94
|
+
key = project.get("project_identifier", "")
|
|
95
|
+
name = project.get("project_name", "")
|
|
96
|
+
count = project.get("ticket_count", 0)
|
|
97
|
+
sections.append(f"- **{key}** - {name} ({count} tickets)")
|
|
98
|
+
sections.append("")
|
|
99
|
+
|
|
100
|
+
# How to use
|
|
101
|
+
sections.append("## How to Use with AI Coding Agents\n")
|
|
102
|
+
sections.append(
|
|
103
|
+
"When working with AI assistants (Claude Code, Cursor, etc.), "
|
|
104
|
+
"you can reference these tickets:\n"
|
|
105
|
+
)
|
|
106
|
+
sections.append('```bash')
|
|
107
|
+
sections.append('# Example prompts:')
|
|
108
|
+
sections.append('"Look at ticket CS-42 and implement the authentication flow"')
|
|
109
|
+
sections.append('"What are the high priority tickets in the Software project?"')
|
|
110
|
+
sections.append('"Which tickets are assigned to me?"')
|
|
111
|
+
sections.append('"Implement the feature described in HL-15"')
|
|
112
|
+
sections.append('```\n')
|
|
113
|
+
|
|
114
|
+
# Keeping in sync
|
|
115
|
+
sections.append("## Keeping Tickets in Sync\n")
|
|
116
|
+
sections.append("To update tickets with latest changes from Janet AI:\n")
|
|
117
|
+
sections.append("```bash")
|
|
118
|
+
sections.append("janet sync")
|
|
119
|
+
sections.append("```\n")
|
|
120
|
+
sections.append(
|
|
121
|
+
"This will update all changed tickets and add new ones. "
|
|
122
|
+
"Run this regularly to keep your local tickets up to date.\n"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# About Janet AI
|
|
126
|
+
sections.append("## About Janet AI\n")
|
|
127
|
+
sections.append(
|
|
128
|
+
"[Janet AI](https://tryjanet.ai) is an AI-native project management platform "
|
|
129
|
+
"designed for modern software teams. It provides:\n"
|
|
130
|
+
)
|
|
131
|
+
sections.append("- AI-powered ticket creation and updates")
|
|
132
|
+
sections.append("- Intelligent ticket prioritization")
|
|
133
|
+
sections.append("- Automated ticket summaries and evaluations")
|
|
134
|
+
sections.append("- Real-time collaboration")
|
|
135
|
+
sections.append("- GitHub integration")
|
|
136
|
+
sections.append("- Discord/Slack integration")
|
|
137
|
+
sections.append("- Meeting and document context linking\n")
|
|
138
|
+
|
|
139
|
+
# Footer
|
|
140
|
+
sections.append("---\n")
|
|
141
|
+
sections.append(
|
|
142
|
+
f"*Generated by [Janet CLI](https://github.com/janet-ai/janet-cli) v0.2.0 "
|
|
143
|
+
f"on {sync_time.strftime('%B %d, %Y at %I:%M %p')}*\n"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return "\n".join(sections)
|
|
147
|
+
|
|
148
|
+
def write_readme(
|
|
149
|
+
self,
|
|
150
|
+
sync_dir: Path,
|
|
151
|
+
org_name: str,
|
|
152
|
+
projects: List[Dict],
|
|
153
|
+
total_tickets: int,
|
|
154
|
+
) -> Path:
|
|
155
|
+
"""
|
|
156
|
+
Write README.md to sync directory.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
sync_dir: Root sync directory
|
|
160
|
+
org_name: Organization name
|
|
161
|
+
projects: List of synced projects
|
|
162
|
+
total_tickets: Total number of tickets synced
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Path to created README
|
|
166
|
+
"""
|
|
167
|
+
sync_time = datetime.utcnow()
|
|
168
|
+
readme_content = self.generate(org_name, projects, total_tickets, sync_time)
|
|
169
|
+
|
|
170
|
+
# Write to root of sync directory
|
|
171
|
+
readme_path = sync_dir / "README.md"
|
|
172
|
+
readme_path.write_text(readme_content, encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
return readme_path
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Main sync orchestration engine."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from typing import List, Dict, Optional
|
|
6
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
7
|
+
|
|
8
|
+
from janet.api.organizations import OrganizationAPI
|
|
9
|
+
from janet.api.projects import ProjectAPI
|
|
10
|
+
from janet.api.tickets import TicketAPI
|
|
11
|
+
from janet.config.manager import ConfigManager
|
|
12
|
+
from janet.markdown.generator import MarkdownGenerator
|
|
13
|
+
from janet.sync.file_manager import FileManager
|
|
14
|
+
from janet.utils.console import console, print_success, print_error, print_info
|
|
15
|
+
from janet.utils.errors import SyncError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SyncEngine:
|
|
19
|
+
"""Main sync orchestration engine."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config_manager: ConfigManager):
|
|
22
|
+
"""
|
|
23
|
+
Initialize sync engine.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_manager: Configuration manager instance
|
|
27
|
+
"""
|
|
28
|
+
self.config_manager = config_manager
|
|
29
|
+
self.config = config_manager.get()
|
|
30
|
+
|
|
31
|
+
# Initialize APIs
|
|
32
|
+
self.org_api = OrganizationAPI(config_manager)
|
|
33
|
+
self.project_api = ProjectAPI(config_manager)
|
|
34
|
+
self.ticket_api = TicketAPI(config_manager)
|
|
35
|
+
|
|
36
|
+
# Initialize sync components
|
|
37
|
+
self.markdown_generator = MarkdownGenerator()
|
|
38
|
+
self.file_manager = FileManager(self.config.sync.root_directory)
|
|
39
|
+
|
|
40
|
+
def sync_all_projects(self) -> Dict:
|
|
41
|
+
"""
|
|
42
|
+
Sync all projects in the current organization.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Summary dictionary with sync statistics
|
|
46
|
+
"""
|
|
47
|
+
if not self.config.selected_organization:
|
|
48
|
+
raise SyncError("No organization selected")
|
|
49
|
+
|
|
50
|
+
org_name = self.config.selected_organization.name
|
|
51
|
+
|
|
52
|
+
# Fetch all projects
|
|
53
|
+
print_info(f"Fetching projects for {org_name}...")
|
|
54
|
+
projects = self.project_api.list_projects()
|
|
55
|
+
|
|
56
|
+
if not projects:
|
|
57
|
+
print_info("No projects found")
|
|
58
|
+
return {"projects_synced": 0, "tickets_synced": 0}
|
|
59
|
+
|
|
60
|
+
console.print(f"\nFound {len(projects)} project(s)")
|
|
61
|
+
|
|
62
|
+
# Sync projects in parallel
|
|
63
|
+
total_tickets = 0
|
|
64
|
+
projects_with_tickets = [p for p in projects if p.get("ticket_count", 0) > 0]
|
|
65
|
+
|
|
66
|
+
# Show projects with no tickets
|
|
67
|
+
for project in projects:
|
|
68
|
+
if project.get("ticket_count", 0) == 0:
|
|
69
|
+
console.print(f" [dim]↳ {project.get('project_identifier', '')} - No tickets[/dim]")
|
|
70
|
+
|
|
71
|
+
# Use ThreadPoolExecutor to sync projects in parallel
|
|
72
|
+
max_workers = min(5, len(projects_with_tickets)) # Max 5 concurrent projects
|
|
73
|
+
|
|
74
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
75
|
+
# Submit all sync tasks
|
|
76
|
+
future_to_project = {
|
|
77
|
+
executor.submit(
|
|
78
|
+
self.sync_project,
|
|
79
|
+
project["id"],
|
|
80
|
+
project.get("project_identifier", ""),
|
|
81
|
+
project.get("project_name", ""),
|
|
82
|
+
): project
|
|
83
|
+
for project in projects_with_tickets
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Wait for completion
|
|
87
|
+
for future in as_completed(future_to_project):
|
|
88
|
+
try:
|
|
89
|
+
synced = future.result()
|
|
90
|
+
total_tickets += synced
|
|
91
|
+
except Exception as e:
|
|
92
|
+
project = future_to_project[future]
|
|
93
|
+
project_key = project.get("project_identifier", "unknown")
|
|
94
|
+
console.print(f"[red]✗ Failed to sync {project_key}: {e}[/red]")
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"projects_synced": len(projects),
|
|
98
|
+
"tickets_synced": total_tickets,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def sync_project(
|
|
102
|
+
self, project_id: str, project_key: str, project_name: str
|
|
103
|
+
) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Sync a single project.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
project_id: Project ID
|
|
109
|
+
project_key: Project key (e.g., "PROJ")
|
|
110
|
+
project_name: Project name
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Number of tickets synced
|
|
114
|
+
"""
|
|
115
|
+
org_name = self.config.selected_organization.name
|
|
116
|
+
|
|
117
|
+
# Fetch all tickets with pagination
|
|
118
|
+
tickets = []
|
|
119
|
+
offset = 0
|
|
120
|
+
limit = 200 # API limit per request
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
response = self.ticket_api.list_tickets(project_id, limit=limit, offset=offset)
|
|
124
|
+
|
|
125
|
+
# Extract tickets from current page
|
|
126
|
+
page_tickets = []
|
|
127
|
+
if "items" in response:
|
|
128
|
+
page_tickets = response["items"]
|
|
129
|
+
elif "kanban_data" in response:
|
|
130
|
+
kanban_data = response["kanban_data"]
|
|
131
|
+
for column in kanban_data.get("columns", []):
|
|
132
|
+
column_tickets = column.get("tickets", [])
|
|
133
|
+
page_tickets.extend(column_tickets)
|
|
134
|
+
elif "tickets" in response:
|
|
135
|
+
page_tickets = response["tickets"]
|
|
136
|
+
|
|
137
|
+
if not page_tickets:
|
|
138
|
+
break # No more tickets
|
|
139
|
+
|
|
140
|
+
tickets.extend(page_tickets)
|
|
141
|
+
|
|
142
|
+
# Check if there are more tickets
|
|
143
|
+
if response.get("has_more"):
|
|
144
|
+
offset = response.get("next_offset", offset + limit)
|
|
145
|
+
else:
|
|
146
|
+
break # No more pages
|
|
147
|
+
|
|
148
|
+
if not tickets:
|
|
149
|
+
console.print(f" [dim]↳ {project_key} - No tickets found[/dim]")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
console.print(f"\n[bold]{project_key}[/bold] - Syncing {len(tickets)} ticket(s)...")
|
|
153
|
+
|
|
154
|
+
# Fetch organization members for name resolution (once per project)
|
|
155
|
+
org_members = self._fetch_org_members()
|
|
156
|
+
|
|
157
|
+
# Batch fetch all full ticket details at once
|
|
158
|
+
ticket_ids = [t.get("id") for t in tickets if t.get("id")]
|
|
159
|
+
|
|
160
|
+
console.print(f" Fetching full details for {len(ticket_ids)} tickets...")
|
|
161
|
+
full_tickets_list = self.ticket_api.batch_fetch(ticket_ids)
|
|
162
|
+
|
|
163
|
+
# Create lookup map by ticket ID
|
|
164
|
+
full_tickets_map = {t.get("id"): t for t in full_tickets_list}
|
|
165
|
+
|
|
166
|
+
# Sync each ticket with progress bar
|
|
167
|
+
synced_count = 0
|
|
168
|
+
with Progress(
|
|
169
|
+
SpinnerColumn(),
|
|
170
|
+
TextColumn("[progress.description]{task.description}"),
|
|
171
|
+
BarColumn(),
|
|
172
|
+
TaskProgressColumn(),
|
|
173
|
+
console=console,
|
|
174
|
+
) as progress:
|
|
175
|
+
task = progress.add_task(
|
|
176
|
+
f"Syncing {project_key}...",
|
|
177
|
+
total=len(tickets),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
for ticket in tickets:
|
|
181
|
+
try:
|
|
182
|
+
# Merge list ticket with full details
|
|
183
|
+
ticket_id = ticket.get("id")
|
|
184
|
+
full_ticket = full_tickets_map.get(ticket_id, {})
|
|
185
|
+
merged_ticket = {**full_ticket, **ticket}
|
|
186
|
+
|
|
187
|
+
self._sync_single_ticket_fast(
|
|
188
|
+
merged_ticket, org_name, project_name, org_members
|
|
189
|
+
)
|
|
190
|
+
synced_count += 1
|
|
191
|
+
except Exception as e:
|
|
192
|
+
ticket_key = ticket.get("ticket_key") or ticket.get("ticket_identifier") or ticket.get("id", "unknown")
|
|
193
|
+
console.print(f" [red]✗ Failed to sync {ticket_key}: {e}[/red]")
|
|
194
|
+
|
|
195
|
+
progress.advance(task)
|
|
196
|
+
|
|
197
|
+
print_success(f"Synced {synced_count}/{len(tickets)} tickets for {project_key}")
|
|
198
|
+
return synced_count
|
|
199
|
+
|
|
200
|
+
def _sync_single_ticket_fast(
|
|
201
|
+
self,
|
|
202
|
+
ticket: Dict,
|
|
203
|
+
org_name: str,
|
|
204
|
+
project_name: str,
|
|
205
|
+
org_members: Optional[List[Dict]] = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Sync a single ticket (optimized - no individual API calls).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
ticket: Merged ticket dictionary (already has full details)
|
|
212
|
+
org_name: Organization name
|
|
213
|
+
project_name: Project name
|
|
214
|
+
org_members: Organization members for name resolution
|
|
215
|
+
"""
|
|
216
|
+
ticket_id = ticket.get("id")
|
|
217
|
+
|
|
218
|
+
# Get ticket_key - try multiple fields
|
|
219
|
+
ticket_key = ticket.get("ticket_key")
|
|
220
|
+
if not ticket_key:
|
|
221
|
+
# Fallback: construct from project_identifier and ticket_identifier
|
|
222
|
+
project_identifier = ticket.get("project_identifier") or project_name
|
|
223
|
+
ticket_identifier = ticket.get("ticket_identifier")
|
|
224
|
+
if project_identifier and ticket_identifier:
|
|
225
|
+
ticket_key = f"{project_identifier}-{ticket_identifier}"
|
|
226
|
+
else:
|
|
227
|
+
ticket_key = ticket.get("ticket_identifier")
|
|
228
|
+
|
|
229
|
+
# Validate required fields
|
|
230
|
+
if not ticket_id:
|
|
231
|
+
raise Exception("Ticket missing 'id' field")
|
|
232
|
+
if not ticket_key:
|
|
233
|
+
raise Exception(f"Ticket {ticket_id} missing 'ticket_key' field")
|
|
234
|
+
|
|
235
|
+
# Ensure ticket has ticket_key for markdown generation
|
|
236
|
+
if "ticket_key" not in ticket:
|
|
237
|
+
ticket["ticket_key"] = ticket_key
|
|
238
|
+
|
|
239
|
+
# Skip attachment fetching to reduce API calls
|
|
240
|
+
# Attachments info is usually in the ticket data already
|
|
241
|
+
attachments = None
|
|
242
|
+
|
|
243
|
+
# Generate markdown
|
|
244
|
+
markdown = self.markdown_generator.generate(
|
|
245
|
+
ticket, org_members, attachments
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Write to file
|
|
249
|
+
self.file_manager.write_ticket(
|
|
250
|
+
org_name=org_name,
|
|
251
|
+
project_name=project_name,
|
|
252
|
+
ticket_key=ticket_key,
|
|
253
|
+
content=markdown,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _fetch_org_members(self) -> Optional[List[Dict]]:
|
|
257
|
+
"""
|
|
258
|
+
Fetch organization members for name resolution.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of organization members or None if fetch fails
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
org_id = self.config.selected_organization.id
|
|
265
|
+
response = self.org_api.get(
|
|
266
|
+
f"/api/v1/organizations/{org_id}/members", include_org=False
|
|
267
|
+
)
|
|
268
|
+
return response.get("members", [])
|
|
269
|
+
except Exception as e:
|
|
270
|
+
console.print(f"[yellow]Warning: Could not fetch organization members: {e}[/yellow]")
|
|
271
|
+
return None
|
janet/utils/__init__.py
ADDED
|
File without changes
|
janet/utils/console.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Rich console utilities for beautiful terminal output."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.theme import Theme
|
|
5
|
+
|
|
6
|
+
# Custom theme for Janet CLI
|
|
7
|
+
janet_theme = Theme(
|
|
8
|
+
{
|
|
9
|
+
"info": "cyan",
|
|
10
|
+
"success": "bold green",
|
|
11
|
+
"warning": "yellow",
|
|
12
|
+
"error": "bold red",
|
|
13
|
+
"highlight": "bold magenta",
|
|
14
|
+
"dim": "dim",
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Global console instance
|
|
19
|
+
console = Console(theme=janet_theme)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_success(message: str) -> None:
|
|
23
|
+
"""Print success message."""
|
|
24
|
+
console.print(f"✓ {message}", style="success")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_error(message: str) -> None:
|
|
28
|
+
"""Print error message."""
|
|
29
|
+
console.print(f"✗ {message}", style="error")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_warning(message: str) -> None:
|
|
33
|
+
"""Print warning message."""
|
|
34
|
+
console.print(f"⚠ {message}", style="warning")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print_info(message: str) -> None:
|
|
38
|
+
"""Print info message."""
|
|
39
|
+
console.print(f"ℹ {message}", style="info")
|