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.
@@ -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
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")