janet-cli 0.2.7__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 +168 -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 +16 -7
- janet_cli-0.2.33.dist-info/METADATA +356 -0
- janet_cli-0.2.33.dist-info/RECORD +34 -0
- janet_cli-0.2.7.dist-info/METADATA +0 -215
- janet_cli-0.2.7.dist-info/RECORD +0 -33
- {janet_cli-0.2.7.dist-info → janet_cli-0.2.33.dist-info}/WHEEL +0 -0
- {janet_cli-0.2.7.dist-info → janet_cli-0.2.33.dist-info}/entry_points.txt +0 -0
- {janet_cli-0.2.7.dist-info → janet_cli-0.2.33.dist-info}/licenses/LICENSE +0 -0
- {janet_cli-0.2.7.dist-info → janet_cli-0.2.33.dist-info}/top_level.txt +0 -0
janet/__init__.py
CHANGED
janet/api/client.py
CHANGED
|
@@ -126,3 +126,39 @@ class APIClient:
|
|
|
126
126
|
raise NetworkError(f"Request timeout after {self.timeout}s")
|
|
127
127
|
except Exception as e:
|
|
128
128
|
raise NetworkError(f"Network error: {e}")
|
|
129
|
+
|
|
130
|
+
def put(
|
|
131
|
+
self, endpoint: str, data: Optional[Dict] = None, include_org: bool = False, **kwargs
|
|
132
|
+
) -> Dict:
|
|
133
|
+
"""
|
|
134
|
+
Make PUT request.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
endpoint: API endpoint (relative to base_url)
|
|
138
|
+
data: Request body data
|
|
139
|
+
include_org: Whether to include organization ID header
|
|
140
|
+
**kwargs: Additional arguments for httpx.put
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Response JSON
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
NetworkError: If request fails
|
|
147
|
+
"""
|
|
148
|
+
url = f"{self.base_url}{endpoint}"
|
|
149
|
+
headers = self._get_headers(include_org=include_org)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
response = httpx.put(
|
|
153
|
+
url, headers=headers, json=data, timeout=self.timeout, **kwargs
|
|
154
|
+
)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
return response.json()
|
|
157
|
+
except httpx.HTTPStatusError as e:
|
|
158
|
+
if e.response.status_code == 401:
|
|
159
|
+
raise AuthenticationError("Authentication failed. Please log in again.")
|
|
160
|
+
raise NetworkError(f"API request failed: {e.response.status_code} {e.response.text}")
|
|
161
|
+
except httpx.TimeoutException:
|
|
162
|
+
raise NetworkError(f"Request timeout after {self.timeout}s")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise NetworkError(f"Network error: {e}")
|
janet/api/projects.py
CHANGED
|
@@ -55,3 +55,23 @@ class ProjectAPI(APIClient):
|
|
|
55
55
|
raise Exception(response.get("error", "Failed to fetch project"))
|
|
56
56
|
|
|
57
57
|
return response.get("project", {})
|
|
58
|
+
|
|
59
|
+
def get_project_columns(self, project_id: str) -> List[Dict]:
|
|
60
|
+
"""
|
|
61
|
+
Get kanban columns (valid statuses) for a project.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
project_id: Project ID
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
List of column dictionaries with status_value, is_resolved, column_order
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
NetworkError: If API request fails
|
|
71
|
+
"""
|
|
72
|
+
response = self.get(f"/api/v1/projects/{project_id}/columns", include_org=True)
|
|
73
|
+
|
|
74
|
+
if not response.get("success"):
|
|
75
|
+
raise Exception(response.get("error", "Failed to fetch project columns"))
|
|
76
|
+
|
|
77
|
+
return response.get("columns", [])
|
janet/api/tickets.py
CHANGED
|
@@ -75,6 +75,30 @@ class TicketAPI(APIClient):
|
|
|
75
75
|
|
|
76
76
|
return response.get("ticket", {})
|
|
77
77
|
|
|
78
|
+
def cli_batch_fetch(self, ticket_ids: List[str]) -> List[Dict]:
|
|
79
|
+
"""
|
|
80
|
+
Fetch multiple tickets using CLI unlimited endpoint - NO 500 LIMIT.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
ticket_ids: List of ticket IDs (unlimited)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of ticket dictionaries
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
NetworkError: If API request fails
|
|
90
|
+
"""
|
|
91
|
+
if not ticket_ids:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
data = {"ticket_ids": ticket_ids}
|
|
95
|
+
response = self.post("/api/v1/cli/tickets/batch", data=data, include_org=True)
|
|
96
|
+
|
|
97
|
+
if not response.get("success"):
|
|
98
|
+
raise Exception(response.get("error", "Failed to batch fetch tickets"))
|
|
99
|
+
|
|
100
|
+
return response.get("tickets", [])
|
|
101
|
+
|
|
78
102
|
def batch_fetch(self, ticket_ids: List[str]) -> List[Dict]:
|
|
79
103
|
"""
|
|
80
104
|
Fetch multiple tickets in one request.
|
|
@@ -141,7 +165,7 @@ class TicketAPI(APIClient):
|
|
|
141
165
|
NetworkError: If API request fails
|
|
142
166
|
"""
|
|
143
167
|
response = self.get(
|
|
144
|
-
f"/api/v1/
|
|
168
|
+
f"/api/v1/attachments/record/ticket/{ticket_id}", include_org=True
|
|
145
169
|
)
|
|
146
170
|
|
|
147
171
|
if not response.get("success"):
|
|
@@ -151,3 +175,146 @@ class TicketAPI(APIClient):
|
|
|
151
175
|
"direct_attachments": response.get("direct_attachments", []),
|
|
152
176
|
"indirect_attachments": response.get("indirect_attachments", []),
|
|
153
177
|
}
|
|
178
|
+
|
|
179
|
+
def batch_fetch_attachments(self, ticket_ids: List[str]) -> Dict[str, Dict]:
|
|
180
|
+
"""
|
|
181
|
+
Batch fetch attachments for multiple tickets in one request.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
ticket_ids: List of ticket IDs
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Dictionary mapping ticket_id to attachments dict
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
NetworkError: If API request fails
|
|
191
|
+
"""
|
|
192
|
+
if not ticket_ids:
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
response = self.post(
|
|
196
|
+
"/api/v1/attachments/batch/tickets",
|
|
197
|
+
data={"ticket_ids": ticket_ids},
|
|
198
|
+
include_org=True,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if not response.get("success"):
|
|
202
|
+
raise Exception(response.get("error", "Failed to batch fetch attachments"))
|
|
203
|
+
|
|
204
|
+
return response.get("attachments_by_ticket", {})
|
|
205
|
+
|
|
206
|
+
def create_ticket(
|
|
207
|
+
self,
|
|
208
|
+
project_id: str,
|
|
209
|
+
title: str,
|
|
210
|
+
description: Optional[str] = None,
|
|
211
|
+
status: Optional[str] = None,
|
|
212
|
+
priority: Optional[str] = None,
|
|
213
|
+
issue_type: Optional[str] = None,
|
|
214
|
+
assignees: Optional[List[str]] = None,
|
|
215
|
+
labels: Optional[List[str]] = None,
|
|
216
|
+
) -> Dict:
|
|
217
|
+
"""
|
|
218
|
+
Create a new ticket.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
project_id: Project ID to create ticket in
|
|
222
|
+
title: Ticket title (required)
|
|
223
|
+
description: Ticket description
|
|
224
|
+
status: Status (e.g., "To Do", "In Progress")
|
|
225
|
+
priority: Priority (Low, Medium, High, Critical)
|
|
226
|
+
issue_type: Type (Task, Bug, Story, Epic)
|
|
227
|
+
assignees: List of assignee emails
|
|
228
|
+
labels: List of label strings
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Dictionary with created ticket details including ticket_key
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
NetworkError: If API request fails
|
|
235
|
+
"""
|
|
236
|
+
payload: Dict = {
|
|
237
|
+
"project_id": project_id,
|
|
238
|
+
"title": title,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if description:
|
|
242
|
+
payload["description"] = description
|
|
243
|
+
if status:
|
|
244
|
+
payload["status"] = status
|
|
245
|
+
if priority:
|
|
246
|
+
payload["priority"] = priority
|
|
247
|
+
if issue_type:
|
|
248
|
+
payload["issue_type"] = issue_type
|
|
249
|
+
if assignees:
|
|
250
|
+
payload["assignees"] = assignees
|
|
251
|
+
if labels:
|
|
252
|
+
payload["labels"] = labels
|
|
253
|
+
|
|
254
|
+
response = self.post("/api/v1/tickets", data=payload, include_org=True)
|
|
255
|
+
|
|
256
|
+
if not response.get("success"):
|
|
257
|
+
raise Exception(response.get("error", "Failed to create ticket"))
|
|
258
|
+
|
|
259
|
+
return response
|
|
260
|
+
|
|
261
|
+
def update_ticket(
|
|
262
|
+
self,
|
|
263
|
+
ticket_id: str,
|
|
264
|
+
title: Optional[str] = None,
|
|
265
|
+
description: Optional[str] = None,
|
|
266
|
+
status: Optional[str] = None,
|
|
267
|
+
priority: Optional[str] = None,
|
|
268
|
+
issue_type: Optional[str] = None,
|
|
269
|
+
assignees: Optional[List[str]] = None,
|
|
270
|
+
labels: Optional[List[str]] = None,
|
|
271
|
+
due_date: Optional[str] = None,
|
|
272
|
+
) -> Dict:
|
|
273
|
+
"""
|
|
274
|
+
Update an existing ticket.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
ticket_id: Ticket ID (UUID) to update
|
|
278
|
+
title: New ticket title
|
|
279
|
+
description: New ticket description
|
|
280
|
+
status: New status (e.g., "To Do", "In Progress", "Done")
|
|
281
|
+
priority: New priority (Low, Medium, High, Critical)
|
|
282
|
+
issue_type: New type (Task, Bug, Story, Epic)
|
|
283
|
+
assignees: New list of assignee emails (replaces existing)
|
|
284
|
+
labels: New list of labels (replaces existing)
|
|
285
|
+
due_date: New due date (ISO format)
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Dictionary with update result
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
NetworkError: If API request fails
|
|
292
|
+
"""
|
|
293
|
+
payload: Dict = {}
|
|
294
|
+
|
|
295
|
+
if title is not None:
|
|
296
|
+
payload["title"] = title
|
|
297
|
+
if description is not None:
|
|
298
|
+
payload["description"] = description
|
|
299
|
+
if status is not None:
|
|
300
|
+
payload["status"] = status
|
|
301
|
+
if priority is not None:
|
|
302
|
+
payload["priority"] = priority
|
|
303
|
+
if issue_type is not None:
|
|
304
|
+
payload["issue_type"] = issue_type
|
|
305
|
+
if assignees is not None:
|
|
306
|
+
payload["assignees"] = assignees
|
|
307
|
+
if labels is not None:
|
|
308
|
+
payload["labels"] = labels
|
|
309
|
+
if due_date is not None:
|
|
310
|
+
payload["due_date"] = due_date
|
|
311
|
+
|
|
312
|
+
if not payload:
|
|
313
|
+
raise Exception("No fields to update")
|
|
314
|
+
|
|
315
|
+
response = self.put(f"/api/v1/tickets/{ticket_id}", data=payload, include_org=True)
|
|
316
|
+
|
|
317
|
+
if not response.get("success"):
|
|
318
|
+
raise Exception(response.get("error", "Failed to update ticket"))
|
|
319
|
+
|
|
320
|
+
return response
|
janet/auth/callback_server.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Local HTTP server for OAuth callback."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import threading
|
|
4
5
|
import urllib.parse
|
|
5
6
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
@@ -282,6 +283,8 @@ class CallbackServer:
|
|
|
282
283
|
port: Port to listen on (default: 8765)
|
|
283
284
|
"""
|
|
284
285
|
self.port = port
|
|
286
|
+
# Use localhost for dev (WorkOS staging), 127.0.0.1 for prod
|
|
287
|
+
self.host = os.getenv("OAUTH_CALLBACK_HOST", "127.0.0.1")
|
|
285
288
|
self.server: Optional[HTTPServer] = None
|
|
286
289
|
self.thread: Optional[threading.Thread] = None
|
|
287
290
|
|
|
@@ -292,10 +295,9 @@ class CallbackServer:
|
|
|
292
295
|
CallbackHandler.error = None
|
|
293
296
|
|
|
294
297
|
# Try to start server on specified port, fallback to next ports if busy
|
|
295
|
-
# Use 127.0.0.1 instead of localhost for WorkOS compatibility
|
|
296
298
|
for attempt_port in range(self.port, self.port + 10):
|
|
297
299
|
try:
|
|
298
|
-
self.server = HTTPServer((
|
|
300
|
+
self.server = HTTPServer((self.host, attempt_port), CallbackHandler)
|
|
299
301
|
self.port = attempt_port
|
|
300
302
|
break
|
|
301
303
|
except OSError:
|
|
@@ -356,5 +358,4 @@ class CallbackServer:
|
|
|
356
358
|
Returns:
|
|
357
359
|
Redirect URI string
|
|
358
360
|
"""
|
|
359
|
-
|
|
360
|
-
return f"http://127.0.0.1:{self.port}/callback"
|
|
361
|
+
return f"http://{self.host}:{self.port}/callback"
|
janet/auth/oauth_flow.py
CHANGED
janet/auth/token_manager.py
CHANGED
|
@@ -19,16 +19,18 @@ class TokenManager:
|
|
|
19
19
|
"""
|
|
20
20
|
self.config_manager = config_manager
|
|
21
21
|
|
|
22
|
-
def get_access_token(self) -> str:
|
|
22
|
+
def get_access_token(self, auto_refresh: bool = True) -> str:
|
|
23
23
|
"""
|
|
24
|
-
Get current access token.
|
|
24
|
+
Get current access token, refreshing if expired.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
auto_refresh: Automatically refresh token if expired
|
|
25
28
|
|
|
26
29
|
Returns:
|
|
27
30
|
Access token string
|
|
28
31
|
|
|
29
32
|
Raises:
|
|
30
|
-
AuthenticationError: If not authenticated
|
|
31
|
-
TokenExpiredError: If token is expired
|
|
33
|
+
AuthenticationError: If not authenticated or refresh fails
|
|
32
34
|
"""
|
|
33
35
|
config = self.config_manager.get()
|
|
34
36
|
|
|
@@ -36,7 +38,12 @@ class TokenManager:
|
|
|
36
38
|
raise AuthenticationError("Not authenticated. Run 'janet login' first.")
|
|
37
39
|
|
|
38
40
|
if self.is_token_expired():
|
|
39
|
-
|
|
41
|
+
if auto_refresh:
|
|
42
|
+
self.refresh_access_token()
|
|
43
|
+
# Re-read config after refresh
|
|
44
|
+
config = self.config_manager.get()
|
|
45
|
+
else:
|
|
46
|
+
raise TokenExpiredError("Access token has expired.")
|
|
40
47
|
|
|
41
48
|
return config.auth.access_token
|
|
42
49
|
|
|
@@ -90,3 +97,22 @@ class TokenManager:
|
|
|
90
97
|
"""
|
|
91
98
|
config = self.config_manager.get()
|
|
92
99
|
return config.auth.user_id
|
|
100
|
+
|
|
101
|
+
def refresh_access_token(self) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Refresh the access token using the refresh token.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
AuthenticationError: If refresh fails or no refresh token
|
|
107
|
+
"""
|
|
108
|
+
from janet.auth.oauth_flow import OAuthFlow
|
|
109
|
+
|
|
110
|
+
config = self.config_manager.get()
|
|
111
|
+
|
|
112
|
+
if not config.auth.refresh_token:
|
|
113
|
+
raise AuthenticationError(
|
|
114
|
+
"No refresh token available. Please run 'janet login' to re-authenticate."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
oauth_flow = OAuthFlow(self.config_manager)
|
|
118
|
+
oauth_flow.refresh_token()
|