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.
- agdt_ai_helpers/__init__.py +34 -0
- agentic_devtools/__init__.py +8 -0
- agentic_devtools/background_tasks.py +598 -0
- agentic_devtools/cli/__init__.py +1 -0
- agentic_devtools/cli/azure_devops/__init__.py +222 -0
- agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
- agentic_devtools/cli/azure_devops/auth.py +34 -0
- agentic_devtools/cli/azure_devops/commands.py +728 -0
- agentic_devtools/cli/azure_devops/config.py +49 -0
- agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
- agentic_devtools/cli/azure_devops/helpers.py +561 -0
- agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
- agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
- agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
- agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
- agentic_devtools/cli/azure_devops/review_commands.py +700 -0
- agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
- agentic_devtools/cli/azure_devops/review_jira.py +308 -0
- agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
- agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
- agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
- agentic_devtools/cli/git/__init__.py +91 -0
- agentic_devtools/cli/git/async_commands.py +294 -0
- agentic_devtools/cli/git/commands.py +399 -0
- agentic_devtools/cli/git/core.py +152 -0
- agentic_devtools/cli/git/diff.py +210 -0
- agentic_devtools/cli/git/operations.py +737 -0
- agentic_devtools/cli/jira/__init__.py +114 -0
- agentic_devtools/cli/jira/adf.py +105 -0
- agentic_devtools/cli/jira/async_commands.py +439 -0
- agentic_devtools/cli/jira/async_status.py +27 -0
- agentic_devtools/cli/jira/commands.py +28 -0
- agentic_devtools/cli/jira/comment_commands.py +141 -0
- agentic_devtools/cli/jira/config.py +69 -0
- agentic_devtools/cli/jira/create_commands.py +293 -0
- agentic_devtools/cli/jira/formatting.py +131 -0
- agentic_devtools/cli/jira/get_commands.py +287 -0
- agentic_devtools/cli/jira/helpers.py +278 -0
- agentic_devtools/cli/jira/parse_error_report.py +352 -0
- agentic_devtools/cli/jira/role_commands.py +560 -0
- agentic_devtools/cli/jira/state_helpers.py +39 -0
- agentic_devtools/cli/jira/update_commands.py +222 -0
- agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
- agentic_devtools/cli/release/__init__.py +5 -0
- agentic_devtools/cli/release/commands.py +113 -0
- agentic_devtools/cli/release/helpers.py +113 -0
- agentic_devtools/cli/runner.py +318 -0
- agentic_devtools/cli/state.py +174 -0
- agentic_devtools/cli/subprocess_utils.py +109 -0
- agentic_devtools/cli/tasks/__init__.py +28 -0
- agentic_devtools/cli/tasks/commands.py +851 -0
- agentic_devtools/cli/testing.py +442 -0
- agentic_devtools/cli/workflows/__init__.py +80 -0
- agentic_devtools/cli/workflows/advancement.py +204 -0
- agentic_devtools/cli/workflows/base.py +240 -0
- agentic_devtools/cli/workflows/checklist.py +278 -0
- agentic_devtools/cli/workflows/commands.py +1610 -0
- agentic_devtools/cli/workflows/manager.py +802 -0
- agentic_devtools/cli/workflows/preflight.py +323 -0
- agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
- agentic_devtools/dispatcher.py +704 -0
- agentic_devtools/file_locking.py +203 -0
- agentic_devtools/prompts/__init__.py +38 -0
- agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
- agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
- agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
- agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
- agentic_devtools/prompts/loader.py +377 -0
- agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
- agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
- agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
- agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
- agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
- agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
- agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
- agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
- agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
- agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
- agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
- agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
- agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
- agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
- agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
- agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
- agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
- agentic_devtools/state.py +754 -0
- agentic_devtools/task_state.py +902 -0
- agentic_devtools-0.2.0.dist-info/METADATA +544 -0
- agentic_devtools-0.2.0.dist-info/RECORD +92 -0
- agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
- agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
- 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
|
+
)
|