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,116 @@
1
+ """Configuration file management."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from janet.config.models import Config
8
+ from janet.utils.errors import ConfigurationError
9
+ from janet.utils.paths import get_config_file
10
+
11
+
12
+ class ConfigManager:
13
+ """Manages configuration file read/write operations."""
14
+
15
+ def __init__(self, config_path: Optional[Path] = None):
16
+ """
17
+ Initialize configuration manager.
18
+
19
+ Args:
20
+ config_path: Optional custom config file path
21
+ """
22
+ self.config_path = config_path or get_config_file()
23
+ self._config: Optional[Config] = None
24
+
25
+ def load(self) -> Config:
26
+ """
27
+ Load configuration from file.
28
+
29
+ Returns:
30
+ Configuration object
31
+
32
+ Raises:
33
+ ConfigurationError: If config file is invalid
34
+ """
35
+ if not self.config_path.exists():
36
+ # Create default configuration
37
+ self._config = Config()
38
+ self.save()
39
+ return self._config
40
+
41
+ try:
42
+ with open(self.config_path, "r") as f:
43
+ data = json.load(f)
44
+ self._config = Config(**data)
45
+ return self._config
46
+ except Exception as e:
47
+ raise ConfigurationError(f"Failed to load configuration: {e}")
48
+
49
+ def save(self) -> None:
50
+ """
51
+ Save configuration to file.
52
+
53
+ Raises:
54
+ ConfigurationError: If unable to save config
55
+ """
56
+ if self._config is None:
57
+ raise ConfigurationError("No configuration loaded")
58
+
59
+ try:
60
+ # Ensure parent directory exists
61
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ # Write config with indentation for readability
64
+ with open(self.config_path, "w") as f:
65
+ json.dump(self._config.model_dump(mode="json"), f, indent=2, default=str)
66
+
67
+ # Set restrictive permissions (user read/write only)
68
+ self.config_path.chmod(0o600)
69
+ except Exception as e:
70
+ raise ConfigurationError(f"Failed to save configuration: {e}")
71
+
72
+ def get(self) -> Config:
73
+ """
74
+ Get current configuration.
75
+
76
+ Returns:
77
+ Configuration object
78
+ """
79
+ if self._config is None:
80
+ self._config = self.load()
81
+ return self._config
82
+
83
+ def update(self, config: Config) -> None:
84
+ """
85
+ Update configuration and save to file.
86
+
87
+ Args:
88
+ config: Updated configuration object
89
+ """
90
+ self._config = config
91
+ self.save()
92
+
93
+ def reset(self) -> None:
94
+ """Reset configuration to defaults."""
95
+ self._config = Config()
96
+ self.save()
97
+
98
+ def is_authenticated(self) -> bool:
99
+ """
100
+ Check if user is authenticated.
101
+
102
+ Returns:
103
+ True if valid access token exists
104
+ """
105
+ config = self.get()
106
+ return config.auth.access_token is not None
107
+
108
+ def has_organization(self) -> bool:
109
+ """
110
+ Check if organization is selected.
111
+
112
+ Returns:
113
+ True if organization is selected
114
+ """
115
+ config = self.get()
116
+ return config.selected_organization is not None
janet/config/models.py ADDED
@@ -0,0 +1,66 @@
1
+ """Pydantic models for configuration."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from typing import Dict, Optional
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class AuthConfig(BaseModel):
10
+ """Authentication configuration."""
11
+
12
+ access_token: Optional[str] = None
13
+ refresh_token: Optional[str] = None
14
+ expires_at: Optional[datetime] = None
15
+ user_id: Optional[str] = None
16
+ user_email: Optional[str] = None
17
+
18
+
19
+ class OrganizationInfo(BaseModel):
20
+ """Organization information."""
21
+
22
+ id: str
23
+ name: str
24
+ uuid: str
25
+
26
+
27
+ class APIConfig(BaseModel):
28
+ """API configuration."""
29
+
30
+ # Production URL by default, override with env var for development
31
+ base_url: str = Field(
32
+ default_factory=lambda: os.getenv(
33
+ "JANET_API_BASE_URL",
34
+ "https://janet-ai-backend-prod-service-11399-85b8d46f-d4i7j6xw.onporter.run"
35
+ )
36
+ )
37
+ timeout: int = 30
38
+
39
+
40
+ class SyncConfig(BaseModel):
41
+ """Sync configuration."""
42
+
43
+ root_directory: str = "~/janet-tickets"
44
+ last_sync_times: Dict[str, str] = Field(default_factory=dict)
45
+ sync_on_init: bool = False
46
+ batch_size: int = 50
47
+
48
+
49
+ class MarkdownConfig(BaseModel):
50
+ """Markdown generation configuration."""
51
+
52
+ include_comments: bool = True
53
+ include_attachments: bool = True
54
+ include_metadata: bool = True
55
+ yjs_fallback_mode: str = "plain_text"
56
+
57
+
58
+ class Config(BaseModel):
59
+ """Complete configuration model."""
60
+
61
+ version: str = "1.0"
62
+ auth: AuthConfig = Field(default_factory=AuthConfig)
63
+ selected_organization: Optional[OrganizationInfo] = None
64
+ api: APIConfig = Field(default_factory=APIConfig)
65
+ sync: SyncConfig = Field(default_factory=SyncConfig)
66
+ markdown: MarkdownConfig = Field(default_factory=MarkdownConfig)
File without changes
@@ -0,0 +1,272 @@
1
+ """Generate markdown from ticket data."""
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, List, Optional
5
+
6
+ from janet.markdown.yjs_converter import YjsConverter
7
+
8
+
9
+ class MarkdownGenerator:
10
+ """
11
+ Generate markdown documents from ticket data.
12
+
13
+ Based on TypeScript implementation in copyTicketAsMarkdown.ts
14
+ """
15
+
16
+ def __init__(self):
17
+ """Initialize markdown generator."""
18
+ self.yjs_converter = YjsConverter()
19
+
20
+ def generate(
21
+ self,
22
+ ticket: Dict,
23
+ organization_members: Optional[List[Dict]] = None,
24
+ attachments: Optional[Dict] = None,
25
+ ) -> str:
26
+ """
27
+ Generate complete markdown document from ticket data.
28
+
29
+ Args:
30
+ ticket: Ticket dictionary
31
+ organization_members: List of organization members (for name resolution)
32
+ attachments: Dictionary with direct_attachments and indirect_attachments
33
+
34
+ Returns:
35
+ Complete markdown string
36
+ """
37
+ sections = []
38
+
39
+ # 1. Title
40
+ ticket_key = ticket.get("ticket_key", "UNKNOWN")
41
+ title = ticket.get("title", "Untitled")
42
+ sections.append(f"# {ticket_key}: {title}\n")
43
+
44
+ # 2. Metadata
45
+ sections.append(self._generate_metadata(ticket, organization_members))
46
+
47
+ # 3. Description
48
+ sections.append(self._generate_description(ticket))
49
+
50
+ # 4. Comments
51
+ if ticket.get("comments"):
52
+ sections.append(
53
+ self._generate_comments(ticket["comments"], organization_members)
54
+ )
55
+
56
+ # 5. Attachments
57
+ if attachments:
58
+ sections.append(self._generate_attachments(attachments, organization_members))
59
+
60
+ # 6. Child Tasks
61
+ if ticket.get("child_tasks"):
62
+ sections.append(self._generate_child_tasks(ticket["child_tasks"]))
63
+
64
+ # 7. Footer
65
+ sections.append(self._generate_footer(ticket_key))
66
+
67
+ return "\n".join(sections)
68
+
69
+ def _generate_metadata(
70
+ self, ticket: Dict, organization_members: Optional[List[Dict]] = None
71
+ ) -> str:
72
+ """Generate metadata section."""
73
+ lines = ["## Metadata\n"]
74
+
75
+ # Status
76
+ lines.append(f"- **Status:** {ticket.get('status', 'Unknown')}")
77
+
78
+ # Priority
79
+ lines.append(f"- **Priority:** {ticket.get('priority', 'Unknown')}")
80
+
81
+ # Type
82
+ lines.append(f"- **Type:** {ticket.get('issue_type', 'Unknown')}")
83
+
84
+ # Assignees
85
+ assignees = ticket.get("assignees", [])
86
+ if assignees:
87
+ assignee_names = [
88
+ self._resolve_user_name(email, organization_members) for email in assignees
89
+ ]
90
+ lines.append(f"- **Assignees:** {', '.join(assignee_names)}")
91
+ else:
92
+ lines.append("- **Assignees:** Unassigned")
93
+
94
+ # Creator
95
+ creator = ticket.get("creator", "Unknown")
96
+ creator_name = self._resolve_user_name(creator, organization_members)
97
+ lines.append(f"- **Creator:** {creator_name}")
98
+
99
+ # Dates
100
+ created_at = ticket.get("created_at", "")
101
+ updated_at = ticket.get("updated_at", "")
102
+ lines.append(f"- **Created:** {self._format_date(created_at)}")
103
+ lines.append(f"- **Updated:** {self._format_date(updated_at)}")
104
+
105
+ # Optional fields
106
+ if ticket.get("story_points"):
107
+ lines.append(f"- **Story Points:** {ticket['story_points']}")
108
+
109
+ if ticket.get("due_date"):
110
+ lines.append(f"- **Due Date:** {self._format_date(ticket['due_date'])}")
111
+
112
+ if ticket.get("sprint"):
113
+ lines.append(f"- **Sprint:** {ticket['sprint']}")
114
+
115
+ if ticket.get("labels"):
116
+ labels = ticket["labels"]
117
+ if labels:
118
+ lines.append(f"- **Labels:** {', '.join(labels)}")
119
+
120
+ lines.append("") # Empty line after metadata
121
+ return "\n".join(lines)
122
+
123
+ def _generate_description(self, ticket: Dict) -> str:
124
+ """Generate description section."""
125
+ lines = ["## Description\n"]
126
+
127
+ yjs_binary = ticket.get("description_yjs_binary")
128
+ plain_text = ticket.get("description")
129
+
130
+ # Convert Yjs binary to markdown
131
+ markdown = self.yjs_converter.convert(yjs_binary, plain_text)
132
+ lines.append(markdown)
133
+
134
+ lines.append("") # Empty line after description
135
+ return "\n".join(lines)
136
+
137
+ def _generate_comments(
138
+ self, comments: List[Dict], organization_members: Optional[List[Dict]] = None
139
+ ) -> str:
140
+ """Generate comments section."""
141
+ lines = [f"## Comments ({len(comments)})\n"]
142
+
143
+ for comment in comments:
144
+ author = comment.get("created_by", "Unknown")
145
+ author_name = self._resolve_user_name(author, organization_members)
146
+ timestamp = self._format_date(comment.get("created_at", ""))
147
+
148
+ lines.append(f"### {author_name} - {timestamp}\n")
149
+ lines.append(comment.get("content", ""))
150
+ lines.append("") # Empty line between comments
151
+
152
+ return "\n".join(lines)
153
+
154
+ def _generate_attachments(
155
+ self, attachments: Dict, organization_members: Optional[List[Dict]] = None
156
+ ) -> str:
157
+ """Generate attachments section."""
158
+ direct = attachments.get("direct_attachments", [])
159
+ indirect = attachments.get("indirect_attachments", [])
160
+ all_attachments = direct + indirect
161
+
162
+ if not all_attachments:
163
+ return ""
164
+
165
+ lines = [f"## Attachments ({len(all_attachments)})\n"]
166
+
167
+ for attachment in all_attachments:
168
+ filename = attachment.get("original_filename", "Unknown")
169
+ lines.append(f"### {filename}")
170
+
171
+ lines.append(f"- **Type:** {attachment.get('mime_type', 'Unknown')}")
172
+
173
+ uploader = attachment.get("uploaded_by", "Unknown")
174
+ uploader_name = self._resolve_user_name(uploader, organization_members)
175
+ lines.append(f"- **Uploaded by:** {uploader_name}")
176
+
177
+ created_at = attachment.get("created_at", "")
178
+ lines.append(f"- **Uploaded:** {self._format_date(created_at)}")
179
+
180
+ if attachment.get("ai_description"):
181
+ lines.append(f"- **AI Description:** {attachment['ai_description']}")
182
+
183
+ lines.append("") # Empty line between attachments
184
+
185
+ return "\n".join(lines)
186
+
187
+ def _generate_child_tasks(self, child_tasks: List[Dict]) -> str:
188
+ """Generate child tasks section."""
189
+ if not child_tasks:
190
+ return ""
191
+
192
+ lines = [f"## Child Tasks ({len(child_tasks)})\n"]
193
+
194
+ for task in child_tasks:
195
+ identifier = task.get("fullIdentifier", task.get("childIdentifier", ""))
196
+ title = task.get("title", "Untitled")
197
+ status = task.get("status", "")
198
+ priority = task.get("priority", "")
199
+
200
+ # Format: - [STATUS] IDENTIFIER: Title (Priority)
201
+ status_badge = f"[{status}]" if status else ""
202
+ priority_info = f"({priority})" if priority else ""
203
+
204
+ line = f"- {status_badge} **{identifier}**: {title}"
205
+ if priority_info:
206
+ line += f" {priority_info}"
207
+
208
+ lines.append(line)
209
+
210
+ lines.append("") # Empty line after child tasks
211
+ return "\n".join(lines)
212
+
213
+ def _generate_footer(self, ticket_key: str) -> str:
214
+ """Generate footer section."""
215
+ export_date = self._format_date(datetime.utcnow().isoformat())
216
+ return f"---\n*Exported from {ticket_key} on {export_date}*"
217
+
218
+ def _resolve_user_name(
219
+ self, email: str, organization_members: Optional[List[Dict]] = None
220
+ ) -> str:
221
+ """
222
+ Resolve user email to display name.
223
+
224
+ Args:
225
+ email: User email
226
+ organization_members: List of organization members
227
+
228
+ Returns:
229
+ Display name or email if not found
230
+ """
231
+ if not organization_members:
232
+ return email
233
+
234
+ for member in organization_members:
235
+ if member.get("email") == email:
236
+ first_name = member.get("firstName", "")
237
+ last_name = member.get("lastName", "")
238
+ if first_name and last_name:
239
+ return f"{first_name} {last_name}"
240
+ elif first_name:
241
+ return first_name
242
+ break
243
+
244
+ return email
245
+
246
+ def _format_date(self, iso_string: str) -> str:
247
+ """
248
+ Format ISO timestamp to human-readable date.
249
+
250
+ Args:
251
+ iso_string: ISO 8601 timestamp
252
+
253
+ Returns:
254
+ Formatted date string
255
+ """
256
+ if not iso_string:
257
+ return "Unknown"
258
+
259
+ try:
260
+ # Parse ISO string (handle both with and without microseconds)
261
+ if "." in iso_string:
262
+ # Has microseconds
263
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
264
+ else:
265
+ # No microseconds
266
+ dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
267
+
268
+ # Format: Jan 15, 2024 10:30 AM
269
+ return dt.strftime("%b %d, %Y %I:%M %p")
270
+ except Exception:
271
+ # Fallback: return as-is
272
+ return iso_string
@@ -0,0 +1,225 @@
1
+ """Convert Yjs binary to markdown using pycrdt."""
2
+
3
+ import base64
4
+ import logging
5
+ from typing import Optional, List
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class YjsConverter:
11
+ """
12
+ Convert Yjs binary to markdown.
13
+
14
+ Uses pycrdt (actively maintained, replaces abandoned y-py).
15
+ Based on TypeScript implementation in copyTicketAsMarkdown.ts
16
+ """
17
+
18
+ def convert(
19
+ self, yjs_binary_base64: Optional[str], plain_text_fallback: Optional[str] = None
20
+ ) -> str:
21
+ """
22
+ Convert Yjs binary to markdown.
23
+
24
+ Args:
25
+ yjs_binary_base64: Base64-encoded Yjs binary
26
+ plain_text_fallback: Plain text description (NOT USED - only for compatibility)
27
+
28
+ Returns:
29
+ Markdown string
30
+ """
31
+ # If no Yjs binary, show placeholder
32
+ if not yjs_binary_base64:
33
+ return "*No description provided*"
34
+
35
+ try:
36
+ # Try pycrdt conversion
37
+ markdown = self._convert_with_pycrdt(yjs_binary_base64)
38
+ if markdown and markdown.strip():
39
+ return markdown
40
+ else:
41
+ return "*[Empty description]*"
42
+ except Exception as e:
43
+ # Show error instead of falling back
44
+ logger.error(f"Yjs conversion failed: {e}")
45
+ return f"*[Yjs conversion failed: {e}]*"
46
+
47
+ def _convert_with_pycrdt(self, base64_str: str) -> str:
48
+ """
49
+ Use pycrdt library to parse Yjs binary and extract text.
50
+
51
+ Args:
52
+ base64_str: Base64-encoded Yjs binary
53
+
54
+ Returns:
55
+ Markdown string
56
+
57
+ Raises:
58
+ Exception: If conversion fails
59
+ """
60
+ try:
61
+ from pycrdt import Doc, XmlElement, XmlText, XmlFragment
62
+ except ImportError:
63
+ raise Exception("pycrdt library not installed. Run: pip install pycrdt")
64
+
65
+ # Decode base64
66
+ binary_data = self._base64_to_bytes(base64_str)
67
+ if not binary_data:
68
+ raise Exception("Failed to decode base64")
69
+
70
+ # Create Doc and apply update
71
+ doc = Doc()
72
+ doc.apply_update(binary_data)
73
+
74
+ # Get the BlockNote editor fragment
75
+ try:
76
+ # pycrdt uses get() with type= parameter
77
+ fragment = doc.get("blocknote-editor", type=XmlFragment)
78
+ except Exception as e:
79
+ raise Exception(f"Failed to get blocknote-editor fragment: {e}")
80
+
81
+ if fragment is None:
82
+ raise Exception("blocknote-editor fragment is None")
83
+
84
+ # Extract text from fragment
85
+ return self._extract_text_from_fragment(fragment)
86
+
87
+ def _base64_to_bytes(self, base64_str: str) -> Optional[bytes]:
88
+ """Convert base64 string to bytes."""
89
+ try:
90
+ if not base64_str or not isinstance(base64_str, str):
91
+ return None
92
+
93
+ cleaned = base64_str.strip()
94
+
95
+ # Validate base64 format
96
+ import re
97
+ if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', cleaned):
98
+ logger.warning("Invalid base64 format detected")
99
+ return None
100
+
101
+ return base64.b64decode(cleaned)
102
+ except Exception as e:
103
+ logger.warning(f"Failed to decode base64: {e}")
104
+ return None
105
+
106
+ def _extract_text_from_fragment(self, fragment) -> str:
107
+ """
108
+ Extract markdown text from XmlFragment.
109
+
110
+ This mimics the TypeScript extractTextFromYDoc function.
111
+
112
+ Args:
113
+ fragment: XmlFragment containing BlockNote content
114
+
115
+ Returns:
116
+ Markdown string
117
+ """
118
+ from pycrdt import XmlElement, XmlText
119
+
120
+ lines: List[str] = []
121
+
122
+ def process_node(node, indent: int = 0, add_blank_line: bool = True):
123
+ """Process a single XML node recursively."""
124
+ try:
125
+ if isinstance(node, XmlText):
126
+ # Handle text nodes - these are inline, no blank line
127
+ text = str(node).strip()
128
+ if text:
129
+ lines.append(" " * indent + text)
130
+
131
+ elif isinstance(node, XmlElement):
132
+ # Handle element nodes
133
+ node_name = node.tag
134
+ children = list(node.children) if hasattr(node, "children") else []
135
+
136
+ # Collect text content from XmlText children
137
+ text_content = "".join(
138
+ str(child) for child in children if isinstance(child, XmlText)
139
+ ).strip()
140
+
141
+ # Node processing (debug removed)
142
+
143
+ # Process based on node type (matching BlockNote structure)
144
+
145
+ # BlockNote wrapper elements - just recurse into children
146
+ if node_name in ("blockGroup", "blockContainer"):
147
+ for child in children:
148
+ process_node(child, indent, add_blank_line)
149
+
150
+ # Actual content blocks
151
+ elif node_name == "heading" and text_content:
152
+ level = int(node.attributes.get("level", 1))
153
+ lines.append("#" * level + " " + text_content)
154
+ lines.append("") # Blank line after heading
155
+
156
+ elif node_name == "paragraph" and text_content:
157
+ lines.append(text_content)
158
+ lines.append("") # Blank line after paragraph
159
+
160
+ elif node_name == "quote" and text_content:
161
+ # BlockNote quote becomes markdown blockquote
162
+ lines.append("> " + text_content)
163
+ lines.append("") # Blank line after quote
164
+
165
+ elif node_name == "bulletListItem" and text_content:
166
+ lines.append(" " * indent + "- " + text_content)
167
+ # Process nested lists
168
+ for child in children:
169
+ if isinstance(child, XmlElement):
170
+ process_node(child, indent + 1, add_blank_line=False)
171
+
172
+ elif node_name == "numberedListItem" and text_content:
173
+ lines.append(" " * indent + "1. " + text_content)
174
+ # Process nested lists
175
+ for child in children:
176
+ if isinstance(child, XmlElement):
177
+ process_node(child, indent + 1, add_blank_line=False)
178
+
179
+ elif node_name == "checkListItem":
180
+ checked = node.attributes.get("checked", "false") == "true"
181
+ checkbox = "[x]" if checked else "[ ]"
182
+ if text_content:
183
+ lines.append(" " * indent + f"- {checkbox} " + text_content)
184
+
185
+ elif node_name == "codeBlock":
186
+ language = node.attributes.get("language", "")
187
+ lines.append(f"```{language}")
188
+ if text_content:
189
+ lines.append(text_content)
190
+ lines.append("```")
191
+ lines.append("")
192
+
193
+ elif node_name == "hardBreak":
194
+ # Hard breaks are inline - ignore them as we add blank lines between blocks
195
+ pass
196
+
197
+ # Handle empty paragraphs or unknown blocks
198
+ elif text_content:
199
+ lines.append(text_content)
200
+ lines.append("")
201
+
202
+ # Always process children for unknown types
203
+ elif node_name not in ("hardBreak",):
204
+ for child in children:
205
+ process_node(child, indent, add_blank_line=True)
206
+
207
+ except Exception as e:
208
+ logger.debug(f"Error processing node: {e}")
209
+
210
+ # Process all top-level children
211
+ try:
212
+ if hasattr(fragment, "children"):
213
+ children_list = list(fragment.children)
214
+ for child in children_list:
215
+ process_node(child)
216
+ else:
217
+ # Try iterating directly
218
+ for child in fragment:
219
+ process_node(child)
220
+ except Exception as e:
221
+ logger.warning(f"Failed to iterate fragment: {e}")
222
+ raise Exception(f"Could not iterate fragment children")
223
+
224
+ result = "\n".join(lines).strip()
225
+ return result if result else ""
janet/sync/__init__.py ADDED
File without changes