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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Janet AI CLI - Sync tickets to local markdown files."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.33"
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
@@ -165,7 +165,7 @@ class TicketAPI(APIClient):
165
165
  NetworkError: If API request fails
166
166
  """
167
167
  response = self.get(
168
- f"/api/v1/tickets/{ticket_id}/attachments", include_org=True
168
+ f"/api/v1/attachments/record/ticket/{ticket_id}", include_org=True
169
169
  )
170
170
 
171
171
  if not response.get("success"):
@@ -175,3 +175,146 @@ class TicketAPI(APIClient):
175
175
  "direct_attachments": response.get("direct_attachments", []),
176
176
  "indirect_attachments": response.get("indirect_attachments", []),
177
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
@@ -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(("127.0.0.1", attempt_port), CallbackHandler)
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
- # WorkOS requires 127.0.0.1 (not localhost) for http in production
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
@@ -267,7 +267,6 @@ class OAuthFlow:
267
267
 
268
268
  # Update tokens
269
269
  self._save_tokens(tokens)
270
- print_info("Access token refreshed")
271
270
 
272
271
  except httpx.HTTPStatusError as e:
273
272
  error_detail = e.response.text
@@ -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
- raise TokenExpiredError("Access token has expired. Attempting refresh...")
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()