agentic-devtools 0.2.0__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.
Files changed (92) hide show
  1. agdt_ai_helpers/__init__.py +34 -0
  2. agentic_devtools/__init__.py +8 -0
  3. agentic_devtools/background_tasks.py +598 -0
  4. agentic_devtools/cli/__init__.py +1 -0
  5. agentic_devtools/cli/azure_devops/__init__.py +222 -0
  6. agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
  7. agentic_devtools/cli/azure_devops/auth.py +34 -0
  8. agentic_devtools/cli/azure_devops/commands.py +728 -0
  9. agentic_devtools/cli/azure_devops/config.py +49 -0
  10. agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
  11. agentic_devtools/cli/azure_devops/helpers.py +561 -0
  12. agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
  13. agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
  14. agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
  15. agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
  16. agentic_devtools/cli/azure_devops/review_commands.py +700 -0
  17. agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
  18. agentic_devtools/cli/azure_devops/review_jira.py +308 -0
  19. agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
  20. agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
  21. agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
  22. agentic_devtools/cli/git/__init__.py +91 -0
  23. agentic_devtools/cli/git/async_commands.py +294 -0
  24. agentic_devtools/cli/git/commands.py +399 -0
  25. agentic_devtools/cli/git/core.py +152 -0
  26. agentic_devtools/cli/git/diff.py +210 -0
  27. agentic_devtools/cli/git/operations.py +737 -0
  28. agentic_devtools/cli/jira/__init__.py +114 -0
  29. agentic_devtools/cli/jira/adf.py +105 -0
  30. agentic_devtools/cli/jira/async_commands.py +439 -0
  31. agentic_devtools/cli/jira/async_status.py +27 -0
  32. agentic_devtools/cli/jira/commands.py +28 -0
  33. agentic_devtools/cli/jira/comment_commands.py +141 -0
  34. agentic_devtools/cli/jira/config.py +69 -0
  35. agentic_devtools/cli/jira/create_commands.py +293 -0
  36. agentic_devtools/cli/jira/formatting.py +131 -0
  37. agentic_devtools/cli/jira/get_commands.py +287 -0
  38. agentic_devtools/cli/jira/helpers.py +278 -0
  39. agentic_devtools/cli/jira/parse_error_report.py +352 -0
  40. agentic_devtools/cli/jira/role_commands.py +560 -0
  41. agentic_devtools/cli/jira/state_helpers.py +39 -0
  42. agentic_devtools/cli/jira/update_commands.py +222 -0
  43. agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
  44. agentic_devtools/cli/release/__init__.py +5 -0
  45. agentic_devtools/cli/release/commands.py +113 -0
  46. agentic_devtools/cli/release/helpers.py +113 -0
  47. agentic_devtools/cli/runner.py +318 -0
  48. agentic_devtools/cli/state.py +174 -0
  49. agentic_devtools/cli/subprocess_utils.py +109 -0
  50. agentic_devtools/cli/tasks/__init__.py +28 -0
  51. agentic_devtools/cli/tasks/commands.py +851 -0
  52. agentic_devtools/cli/testing.py +442 -0
  53. agentic_devtools/cli/workflows/__init__.py +80 -0
  54. agentic_devtools/cli/workflows/advancement.py +204 -0
  55. agentic_devtools/cli/workflows/base.py +240 -0
  56. agentic_devtools/cli/workflows/checklist.py +278 -0
  57. agentic_devtools/cli/workflows/commands.py +1610 -0
  58. agentic_devtools/cli/workflows/manager.py +802 -0
  59. agentic_devtools/cli/workflows/preflight.py +323 -0
  60. agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
  61. agentic_devtools/dispatcher.py +704 -0
  62. agentic_devtools/file_locking.py +203 -0
  63. agentic_devtools/prompts/__init__.py +38 -0
  64. agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
  65. agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
  66. agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
  67. agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
  68. agentic_devtools/prompts/loader.py +377 -0
  69. agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
  70. agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
  71. agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
  72. agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
  73. agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
  74. agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
  75. agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
  76. agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
  77. agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
  78. agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
  79. agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
  80. agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
  81. agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
  82. agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
  83. agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
  84. agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
  85. agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
  86. agentic_devtools/state.py +754 -0
  87. agentic_devtools/task_state.py +902 -0
  88. agentic_devtools-0.2.0.dist-info/METADATA +544 -0
  89. agentic_devtools-0.2.0.dist-info/RECORD +92 -0
  90. agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
  91. agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
  92. agentic_devtools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,754 @@
1
+ """
2
+ State management using a single JSON file.
3
+
4
+ All AI helper state is stored in a single JSON file (agdt-state.json),
5
+ making it easy to inspect, debug, and manage state across commands.
6
+
7
+ Key design decisions:
8
+ - Single JSON file instead of multiple temp files
9
+ - Direct parameter passing (no replacement tokens needed!)
10
+ - Multiline content works natively in Python CLI
11
+ - Auto-approvable commands in VS Code
12
+ - File locking to prevent race conditions between concurrent tasks
13
+ - Background task tracking via backgroundTasks property
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from .file_locking import FileLockError, locked_state_file
23
+
24
+ STATE_FILENAME = "agdt-state.json"
25
+
26
+ # Default lock timeout in seconds
27
+ DEFAULT_LOCK_TIMEOUT = 5.0
28
+
29
+
30
+ def _get_git_repo_root() -> Optional[Path]:
31
+ """
32
+ Get the git repository or worktree root using git rev-parse.
33
+
34
+ This reliably finds the root of the current repo or worktree,
35
+ regardless of how deep in the directory tree we are.
36
+
37
+ Returns:
38
+ Path to the repo/worktree root, or None if not in a git repo.
39
+ """
40
+ try:
41
+ result = subprocess.run(
42
+ ["git", "rev-parse", "--show-toplevel"],
43
+ capture_output=True,
44
+ text=True,
45
+ check=False,
46
+ )
47
+ if result.returncode == 0 and result.stdout.strip():
48
+ return Path(result.stdout.strip())
49
+ except (FileNotFoundError, OSError):
50
+ pass
51
+ return None
52
+
53
+
54
+ def get_state_dir() -> Path:
55
+ """
56
+ Get the directory for storing the state file.
57
+
58
+ Priority:
59
+ 1. AGENTIC_DEVTOOLS_STATE_DIR environment variable
60
+ 2. DFLY_AI_HELPERS_STATE_DIR environment variable (legacy)
61
+ 3. scripts/temp relative to git repo/worktree root (auto-detected via git)
62
+ 4. scripts/temp found by walking up from cwd (fallback if not in git repo)
63
+ 5. Current working directory / .agdt-temp (final fallback)
64
+
65
+ The function uses `git rev-parse --show-toplevel` to reliably find the
66
+ repo/worktree root, which works correctly even in deep subdirectories
67
+ and in git worktrees.
68
+ """
69
+ # Check environment variable first
70
+ env_dir = os.environ.get("AGENTIC_DEVTOOLS_STATE_DIR") or os.environ.get(
71
+ "DFLY_AI_HELPERS_STATE_DIR"
72
+ )
73
+ if env_dir:
74
+ path = Path(env_dir)
75
+ path.mkdir(parents=True, exist_ok=True)
76
+ return path
77
+
78
+ # Try to find repo root via git (works for both main repo and worktrees)
79
+ git_root = _get_git_repo_root()
80
+ if git_root:
81
+ scripts_dir = git_root / "scripts"
82
+ if scripts_dir.is_dir():
83
+ scripts_temp = scripts_dir / "temp"
84
+ scripts_temp.mkdir(exist_ok=True)
85
+ return scripts_temp
86
+
87
+ # Fallback: Walk up from cwd looking for scripts directory
88
+ # (for cases where git is not available)
89
+ cwd = Path.cwd()
90
+ for parent in [cwd] + list(cwd.parents):
91
+ scripts_dir = parent / "scripts"
92
+ if scripts_dir.is_dir():
93
+ # Found scripts directory - use or create scripts/temp
94
+ scripts_temp = scripts_dir / "temp"
95
+ scripts_temp.mkdir(exist_ok=True)
96
+ return scripts_temp
97
+ # Also check if we're inside a scripts directory
98
+ if parent.name == "scripts" and parent.is_dir():
99
+ temp_dir = parent / "temp"
100
+ temp_dir.mkdir(exist_ok=True)
101
+ return temp_dir
102
+
103
+ # Final fallback to .agdt-temp in cwd
104
+ fallback = cwd / ".agdt-temp"
105
+ fallback.mkdir(exist_ok=True)
106
+ return fallback
107
+
108
+
109
+ def get_state_file_path() -> Path:
110
+ """Get the full path to the state JSON file."""
111
+ return get_state_dir() / STATE_FILENAME
112
+
113
+
114
+ def load_state(
115
+ use_locking: bool = False, lock_timeout: float = DEFAULT_LOCK_TIMEOUT
116
+ ) -> Dict[str, Any]:
117
+ """
118
+ Load the current state from the JSON file.
119
+
120
+ Args:
121
+ use_locking: If True, acquire a shared lock before reading (for concurrent access safety)
122
+ lock_timeout: Maximum time to wait for lock in seconds
123
+
124
+ Returns:
125
+ Dictionary of all state values, empty dict if file doesn't exist
126
+ """
127
+ path = get_state_file_path()
128
+
129
+ if not path.exists():
130
+ return {}
131
+
132
+ try:
133
+ if use_locking:
134
+ with locked_state_file(path, timeout=lock_timeout) as f:
135
+ content = f.read()
136
+ return json.loads(content) if content.strip() else {}
137
+ else:
138
+ content = path.read_text(encoding="utf-8")
139
+ return json.loads(content) if content.strip() else {}
140
+ except json.JSONDecodeError:
141
+ return {}
142
+ except FileLockError:
143
+ # If we can't acquire lock, fall back to unlocked read
144
+ content = path.read_text(encoding="utf-8")
145
+ return json.loads(content) if content.strip() else {}
146
+
147
+
148
+ def save_state(
149
+ state: Dict[str, Any],
150
+ use_locking: bool = False,
151
+ lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
152
+ ) -> Path:
153
+ """
154
+ Save the state dictionary to the JSON file.
155
+
156
+ Args:
157
+ state: Dictionary of state values
158
+ use_locking: If True, acquire an exclusive lock before writing (for concurrent access safety)
159
+ lock_timeout: Maximum time to wait for lock in seconds
160
+
161
+ Returns:
162
+ Path to the state file
163
+ """
164
+ path = get_state_file_path()
165
+ path.parent.mkdir(parents=True, exist_ok=True)
166
+
167
+ content = json.dumps(state, indent=2, ensure_ascii=False)
168
+
169
+ if use_locking:
170
+ try:
171
+ with locked_state_file(path, timeout=lock_timeout) as f:
172
+ f.seek(0)
173
+ f.write(content)
174
+ f.truncate()
175
+ except FileLockError:
176
+ # If we can't acquire lock, fall back to unlocked write
177
+ path.write_text(content, encoding="utf-8")
178
+ else:
179
+ path.write_text(content, encoding="utf-8")
180
+
181
+ return path
182
+
183
+
184
+ def load_state_locked(lock_timeout: float = DEFAULT_LOCK_TIMEOUT) -> Dict[str, Any]:
185
+ """
186
+ Load state with file locking enabled.
187
+
188
+ Convenience function for operations that need concurrent access safety.
189
+
190
+ Args:
191
+ lock_timeout: Maximum time to wait for lock in seconds
192
+
193
+ Returns:
194
+ Dictionary of all state values
195
+ """
196
+ return load_state(use_locking=True, lock_timeout=lock_timeout)
197
+
198
+
199
+ def save_state_locked(
200
+ state: Dict[str, Any], lock_timeout: float = DEFAULT_LOCK_TIMEOUT
201
+ ) -> Path:
202
+ """
203
+ Save state with file locking enabled.
204
+
205
+ Convenience function for operations that need concurrent access safety.
206
+
207
+ Args:
208
+ state: Dictionary of state values
209
+ lock_timeout: Maximum time to wait for lock in seconds
210
+
211
+ Returns:
212
+ Path to the state file
213
+ """
214
+ return save_state(state, use_locking=True, lock_timeout=lock_timeout)
215
+
216
+
217
+ def get_value(key: str, required: bool = False) -> Optional[Any]:
218
+ """
219
+ Get a value from state by key.
220
+
221
+ Supports dot notation for nested keys:
222
+ - 'pull_request_id' -> state['pull_request_id']
223
+ - 'jira.summary' -> state['jira']['summary']
224
+
225
+ Args:
226
+ key: State key (e.g., 'pull_request_id', 'jira.summary')
227
+ required: If True, raise error when key doesn't exist
228
+
229
+ Returns:
230
+ Value or None if not found
231
+ """
232
+ state = load_state()
233
+
234
+ # Support dot notation for nested keys
235
+ parts = key.split(".")
236
+ current = state
237
+
238
+ for part in parts:
239
+ if not isinstance(current, dict) or part not in current:
240
+ if required:
241
+ raise KeyError(f"Required state key not found: {key}")
242
+ return None
243
+ current = current[part]
244
+
245
+ return current
246
+
247
+
248
+ def set_value(key: str, value: Any) -> None:
249
+ """
250
+ Set a value in state.
251
+
252
+ Supports dot notation for nested keys:
253
+ - 'pull_request_id' -> state['pull_request_id'] = value
254
+ - 'jira.summary' -> state['jira']['summary'] = value
255
+
256
+ Args:
257
+ key: State key (e.g., 'pull_request_id', 'jira.summary')
258
+ value: Value to store (can be any JSON-serializable type)
259
+ """
260
+ state = load_state()
261
+
262
+ # Support dot notation for nested keys
263
+ parts = key.split(".")
264
+
265
+ if len(parts) == 1:
266
+ # Simple key
267
+ state[key] = value
268
+ else:
269
+ # Nested key - traverse and create intermediate dicts as needed
270
+ current = state
271
+ for part in parts[:-1]:
272
+ if part not in current or not isinstance(current[part], dict):
273
+ current[part] = {}
274
+ current = current[part]
275
+ current[parts[-1]] = value
276
+
277
+ save_state(state)
278
+
279
+
280
+ # Context-switching keys that trigger temp folder clearing
281
+ CONTEXT_SWITCH_KEYS = {"pull_request_id", "jira.issue_key"}
282
+
283
+
284
+ def set_context_value(
285
+ key: str,
286
+ value: Any,
287
+ trigger_cross_lookup: bool = True,
288
+ verbose: bool = True,
289
+ ) -> bool:
290
+ """
291
+ Set a context-switching value (pull_request_id or jira.issue_key).
292
+
293
+ When one of these primary context keys changes to a NEW value:
294
+ 1. Clears the entire temp folder (preserving the new value)
295
+ 2. Optionally triggers a background cross-lookup for the related key
296
+
297
+ This ensures that switching to a new PR or Jira issue starts with a clean slate,
298
+ removing all temp files, prompts, and queues from the previous context.
299
+
300
+ Cross-lookup behavior:
301
+ - pull_request_id change -> looks up jira.issue_key from PR source branch/title
302
+ - jira.issue_key change -> looks up pull_request_id from Jira/Azure DevOps
303
+
304
+ Args:
305
+ key: Must be "pull_request_id" or "jira.issue_key"
306
+ value: The new value to set
307
+ trigger_cross_lookup: If True, start background task to find related key
308
+ verbose: If True, print status messages
309
+
310
+ Returns:
311
+ True if the value changed (and temp was cleared), False if unchanged
312
+
313
+ Raises:
314
+ ValueError: If key is not a context-switching key
315
+ """
316
+ if key not in CONTEXT_SWITCH_KEYS:
317
+ raise ValueError(f"set_context_value only accepts: {CONTEXT_SWITCH_KEYS}")
318
+
319
+ # Normalize value for comparison (convert to string for consistency)
320
+ normalized_value = str(value) if value is not None else None
321
+
322
+ # Get current value
323
+ current_value = get_value(key)
324
+ current_normalized = str(current_value) if current_value is not None else None
325
+
326
+ # If value hasn't changed, just return (no clearing needed)
327
+ if normalized_value == current_normalized:
328
+ if verbose:
329
+ print(f"ℹ️ {key} unchanged (already set to {value})")
330
+ return False
331
+
332
+ # Value is changing - clear temp folder but preserve the new context value
333
+ if verbose:
334
+ if current_value is not None:
335
+ print(f"🔄 Context switch: {key} changing from {current_value} to {value}")
336
+ else:
337
+ print(f"🔄 Setting context: {key} = {value}")
338
+ print("✓ Clearing temp folder for fresh context...")
339
+
340
+ # Build preserved state with the new value
341
+ # Note: The elif branch below is guaranteed to be True after the if-branch is False
342
+ # because set_context_value validates that key must be one of these two values.
343
+ # This makes the 333->336 branch (elif=False) unreachable.
344
+ preserve = {}
345
+ if key == "pull_request_id":
346
+ preserve["pull_request_id"] = value
347
+ elif key == "jira.issue_key": # pragma: no branch
348
+ preserve["jira"] = {"issue_key": value}
349
+
350
+ clear_temp_folder(preserve_keys=preserve)
351
+
352
+ # Trigger cross-lookup in background if requested
353
+ if trigger_cross_lookup:
354
+ _trigger_cross_lookup(key, value, verbose)
355
+
356
+ return True
357
+
358
+
359
+ def _trigger_cross_lookup(key: str, value: Any, verbose: bool = True) -> None:
360
+ """
361
+ Trigger a background task to find the related context key.
362
+
363
+ Args:
364
+ key: The key that was just set ("pull_request_id" or "jira.issue_key")
365
+ value: The value that was set
366
+ verbose: Whether to print status messages
367
+ """
368
+ if key == "pull_request_id":
369
+ # PR ID was set -> look up the Jira issue key from PR details
370
+ if verbose:
371
+ print(f"🔍 Starting background lookup for Jira issue from PR #{value}...")
372
+ _start_jira_lookup_from_pr(int(value))
373
+
374
+ elif key == "jira.issue_key":
375
+ # Jira issue key was set -> look up the PR ID
376
+ if verbose:
377
+ print(f"🔍 Starting background lookup for PR from Jira issue {value}...")
378
+ _start_pr_lookup_from_jira(str(value))
379
+
380
+
381
+ def _start_jira_lookup_from_pr(pull_request_id: int) -> None:
382
+ """
383
+ Start a background task to find Jira issue key from a PR.
384
+
385
+ Extracts issue key from PR source branch name (e.g., feature/DFLY-1234/...).
386
+ """
387
+ try:
388
+ from .cli.azure_devops.async_commands import lookup_jira_issue_from_pr_async
389
+
390
+ lookup_jira_issue_from_pr_async(pull_request_id)
391
+ except ImportError:
392
+ # Silently fail if async module not available
393
+ pass
394
+ except Exception:
395
+ # Don't let lookup failures break the main flow
396
+ pass
397
+
398
+
399
+ def _start_pr_lookup_from_jira(issue_key: str) -> None:
400
+ """
401
+ Start a background task to find PR from a Jira issue key.
402
+
403
+ Searches for PR linked in Jira comments or by branch name.
404
+ """
405
+ try:
406
+ from .cli.azure_devops.async_commands import lookup_pr_from_jira_issue_async
407
+
408
+ lookup_pr_from_jira_issue_async(issue_key)
409
+ except ImportError:
410
+ # Silently fail if async module not available
411
+ pass
412
+ except Exception:
413
+ # Don't let lookup failures break the main flow
414
+ pass
415
+
416
+
417
+ def delete_value(key: str) -> bool:
418
+ """
419
+ Delete a value from state.
420
+
421
+ Supports dot notation for nested keys:
422
+ - 'pull_request_id' -> deletes state['pull_request_id']
423
+ - 'jira.summary' -> deletes state['jira']['summary']
424
+
425
+ Returns:
426
+ True if key was deleted, False if it didn't exist
427
+ """
428
+ state = load_state()
429
+
430
+ # Support dot notation for nested keys
431
+ parts = key.split(".")
432
+
433
+ if len(parts) == 1:
434
+ # Simple key
435
+ if key in state:
436
+ del state[key]
437
+ save_state(state)
438
+ return True
439
+ return False
440
+ else:
441
+ # Nested key - traverse to parent
442
+ current = state
443
+ for part in parts[:-1]:
444
+ if not isinstance(current, dict) or part not in current:
445
+ return False
446
+ current = current[part]
447
+
448
+ final_key = parts[-1]
449
+ if isinstance(current, dict) and final_key in current:
450
+ del current[final_key]
451
+ save_state(state)
452
+ return True
453
+ return False
454
+
455
+
456
+ def clear_temp_folder(preserve_keys: Optional[Dict[str, Any]] = None) -> None:
457
+ """
458
+ Clear the entire temp folder, removing all state and temporary files.
459
+
460
+ This removes:
461
+ - agdt-state.json (the state file)
462
+ - pull-request-review/ (PR review queue and prompts)
463
+ - background-tasks/ (background task state)
464
+ - All temp-*.json and temp-*.md files
465
+
466
+ Note: The Jira CA bundle (jira_ca_bundle.pem) is now stored in scripts/
467
+ (version-controlled), not in temp/, so it won't be affected by clearing.
468
+
469
+ Args:
470
+ preserve_keys: Optional dict of state keys to preserve after clearing.
471
+ These will be written to a fresh state file.
472
+ """
473
+ import shutil
474
+
475
+ temp_dir = get_state_dir()
476
+
477
+ if temp_dir.exists():
478
+ # Delete everything in the temp folder
479
+ for item in temp_dir.iterdir():
480
+ try:
481
+ if item.is_dir():
482
+ shutil.rmtree(item)
483
+ else:
484
+ item.unlink()
485
+ except OSError:
486
+ # Ignore errors (file in use, permission issues, etc.)
487
+ pass
488
+ else:
489
+ # Create the temp directory if it doesn't exist
490
+ temp_dir.mkdir(parents=True, exist_ok=True)
491
+
492
+ # Restore preserved keys to fresh state file if provided
493
+ if preserve_keys:
494
+ save_state(preserve_keys)
495
+
496
+
497
+ def clear_state() -> None:
498
+ """
499
+ Clear all state by removing the entire temp folder contents.
500
+
501
+ This is now an alias for clear_temp_folder() for backward compatibility.
502
+ """
503
+ clear_temp_folder()
504
+
505
+
506
+ def get_all_keys() -> List[str]:
507
+ """Get list of all keys in state."""
508
+ return list(load_state().keys())
509
+
510
+
511
+ # Convenience functions for common parameters
512
+
513
+
514
+ def get_pull_request_id(required: bool = False) -> Optional[int]:
515
+ """Get the pull request ID from state."""
516
+ value = get_value("pull_request_id", required=required)
517
+ return int(value) if value is not None else None
518
+
519
+
520
+ def set_pull_request_id(pull_request_id: int) -> None:
521
+ """Set the pull request ID in state."""
522
+ set_value("pull_request_id", pull_request_id)
523
+
524
+
525
+ def get_thread_id(required: bool = False) -> Optional[int]:
526
+ """Get the thread ID from state."""
527
+ value = get_value("thread_id", required=required)
528
+ return int(value) if value is not None else None
529
+
530
+
531
+ def set_thread_id(thread_id: int) -> None:
532
+ """Set the thread ID in state."""
533
+ set_value("thread_id", thread_id)
534
+
535
+
536
+ def is_dry_run() -> bool:
537
+ """Check if dry run mode is enabled."""
538
+ value = get_value("dry_run")
539
+ if value is None:
540
+ return False
541
+ if isinstance(value, bool):
542
+ return value
543
+ return str(value).lower() in ("1", "true", "yes")
544
+
545
+
546
+ def set_dry_run(enabled: bool) -> None:
547
+ """Set dry run mode."""
548
+ set_value("dry_run", enabled)
549
+
550
+
551
+ def get_pypi_package_name(required: bool = False) -> Optional[str]:
552
+ """Get the PyPI package name from state."""
553
+ value = get_value("pypi.package_name", required=required)
554
+ return str(value) if value is not None else None
555
+
556
+
557
+ def set_pypi_package_name(package_name: str) -> None:
558
+ """Set the PyPI package name in state."""
559
+ set_value("pypi.package_name", package_name)
560
+
561
+
562
+ def get_pypi_version(required: bool = False) -> Optional[str]:
563
+ """Get the PyPI version from state."""
564
+ value = get_value("pypi.version", required=required)
565
+ return str(value) if value is not None else None
566
+
567
+
568
+ def set_pypi_version(version: str) -> None:
569
+ """Set the PyPI version in state."""
570
+ set_value("pypi.version", version)
571
+
572
+
573
+ def get_pypi_repository(required: bool = False) -> Optional[str]:
574
+ """Get the PyPI repository from state (pypi/testpypi)."""
575
+ value = get_value("pypi.repository", required=required)
576
+ return str(value) if value is not None else None
577
+
578
+
579
+ def set_pypi_repository(repository: str) -> None:
580
+ """Set the PyPI repository in state (pypi/testpypi)."""
581
+ set_value("pypi.repository", repository)
582
+
583
+
584
+ def get_pypi_dry_run() -> bool:
585
+ """Check if the PyPI dry-run mode is enabled."""
586
+ value = get_value("pypi.dry_run")
587
+ if value is None:
588
+ return False
589
+ if isinstance(value, bool):
590
+ return value
591
+ return str(value).lower() in ("1", "true", "yes")
592
+
593
+
594
+ def set_pypi_dry_run(enabled: bool) -> None:
595
+ """Set the PyPI dry-run mode."""
596
+ set_value("pypi.dry_run", enabled)
597
+
598
+
599
+ def should_resolve_thread() -> bool:
600
+ """Check if thread should be resolved after reply."""
601
+ value = get_value("resolve_thread")
602
+ if value is None:
603
+ return False
604
+ if isinstance(value, bool):
605
+ return value
606
+ return str(value).lower() in ("1", "true", "yes")
607
+
608
+
609
+ def set_resolve_thread(enabled: bool) -> None:
610
+ """Set whether to resolve thread after reply."""
611
+ set_value("resolve_thread", enabled)
612
+
613
+
614
+ # Workflow state management
615
+
616
+
617
+ def get_workflow_state() -> Optional[Dict[str, Any]]:
618
+ """
619
+ Get the current workflow state.
620
+
621
+ Returns:
622
+ Dictionary with workflow state or None if no workflow is active.
623
+ Structure: {
624
+ "active": str, # Workflow name (e.g., "pull-request-review")
625
+ "status": str, # Status (e.g., "initiated", "in-progress", "completed")
626
+ "step": str, # Current step name (e.g., "initiate", "review-file")
627
+ "started_at": str, # ISO timestamp when workflow started
628
+ "context": dict # Workflow-specific context data
629
+ }
630
+ """
631
+ return get_value("workflow")
632
+
633
+
634
+ def set_workflow_state(
635
+ name: str,
636
+ status: str,
637
+ step: Optional[str] = None,
638
+ context: Optional[Dict[str, Any]] = None,
639
+ ) -> None:
640
+ """
641
+ Set the workflow state.
642
+
643
+ Args:
644
+ name: Workflow name (e.g., "pull-request-review", "work-on-jira-issue")
645
+ status: Workflow status (e.g., "initiated", "in-progress", "completed")
646
+ step: Current step within the workflow (e.g., "initiate", "review-file")
647
+ context: Workflow-specific context data (e.g., PR ID, Jira key)
648
+ """
649
+ from datetime import datetime, timezone
650
+
651
+ # Get existing workflow state to preserve started_at if updating
652
+ existing = get_workflow_state()
653
+ started_at = (
654
+ existing.get("started_at")
655
+ if existing and existing.get("active") == name
656
+ else datetime.now(timezone.utc).isoformat()
657
+ )
658
+
659
+ workflow_data: Dict[str, Any] = {
660
+ "active": name,
661
+ "status": status,
662
+ "started_at": started_at,
663
+ }
664
+
665
+ if step is not None:
666
+ workflow_data["step"] = step
667
+
668
+ if context is not None:
669
+ # Merge with existing context if updating same workflow
670
+ if existing and existing.get("active") == name:
671
+ existing_context = existing.get("context", {})
672
+ merged = {**existing_context, **context}
673
+ # Remove keys explicitly set to None (allows clearing nested values)
674
+ workflow_data["context"] = {
675
+ k: v for k, v in merged.items() if v is not None
676
+ }
677
+ else:
678
+ workflow_data["context"] = context
679
+ elif existing and existing.get("active") == name:
680
+ # Preserve existing context if not provided
681
+ workflow_data["context"] = existing.get("context", {})
682
+
683
+ set_value("workflow", workflow_data)
684
+
685
+
686
+ def clear_workflow_state() -> None:
687
+ """Clear the workflow state (end the current workflow)."""
688
+ delete_value("workflow")
689
+
690
+
691
+ def is_workflow_active(workflow_name: Optional[str] = None) -> bool:
692
+ """
693
+ Check if a workflow is currently active.
694
+
695
+ Args:
696
+ workflow_name: If provided, check if this specific workflow is active.
697
+ If None, check if any workflow is active.
698
+
699
+ Returns:
700
+ True if a workflow (or the specified workflow) is active
701
+ """
702
+ workflow = get_workflow_state()
703
+ if workflow is None:
704
+ return False
705
+
706
+ if workflow_name is not None:
707
+ return workflow.get("active") == workflow_name
708
+
709
+ return bool(workflow.get("active"))
710
+
711
+
712
+ def update_workflow_step(step: str, status: Optional[str] = None) -> None:
713
+ """
714
+ Update the current workflow step (and optionally status).
715
+
716
+ Args:
717
+ step: New step name
718
+ status: New status (defaults to keeping current status)
719
+
720
+ Raises:
721
+ ValueError: If no workflow is active
722
+ """
723
+ workflow = get_workflow_state()
724
+ if workflow is None:
725
+ raise ValueError("No workflow is currently active")
726
+
727
+ set_workflow_state(
728
+ name=workflow["active"],
729
+ status=status if status is not None else workflow.get("status", "in-progress"),
730
+ step=step,
731
+ context=workflow.get("context"),
732
+ )
733
+
734
+
735
+ def update_workflow_context(context: Dict[str, Any]) -> None:
736
+ """
737
+ Update the workflow context (merges with existing context).
738
+
739
+ Args:
740
+ context: Context data to merge
741
+
742
+ Raises:
743
+ ValueError: If no workflow is active
744
+ """
745
+ workflow = get_workflow_state()
746
+ if workflow is None:
747
+ raise ValueError("No workflow is currently active")
748
+
749
+ set_workflow_state(
750
+ name=workflow["active"],
751
+ status=workflow.get("status", "in-progress"),
752
+ step=workflow.get("step"),
753
+ context=context,
754
+ )