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,34 @@
|
|
|
1
|
+
"""Compatibility shim for the legacy agdt_ai_helpers package name."""
|
|
2
|
+
|
|
3
|
+
import importlib.abc
|
|
4
|
+
import importlib.util
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
import sys as _sys
|
|
7
|
+
|
|
8
|
+
_new_pkg = import_module("agentic_devtools")
|
|
9
|
+
_sys.modules[__name__] = _new_pkg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _AliasFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
13
|
+
"""Redirect agdt_ai_helpers.* imports to agentic_devtools.* modules."""
|
|
14
|
+
|
|
15
|
+
def find_spec(self, fullname, path, target=None):
|
|
16
|
+
if not fullname.startswith("agdt_ai_helpers."):
|
|
17
|
+
return None
|
|
18
|
+
target_name = "agentic_devtools" + fullname[len("agdt_ai_helpers") :]
|
|
19
|
+
target_spec = importlib.util.find_spec(target_name)
|
|
20
|
+
if target_spec is None:
|
|
21
|
+
return None
|
|
22
|
+
return importlib.util.spec_from_loader(fullname, self)
|
|
23
|
+
|
|
24
|
+
def create_module(self, spec): # pragma: no cover - default module creation
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
def exec_module(self, module):
|
|
28
|
+
target_name = "agentic_devtools" + module.__name__[len("agdt_ai_helpers") :]
|
|
29
|
+
target_module = import_module(target_name)
|
|
30
|
+
_sys.modules[module.__name__] = target_module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if not any(isinstance(finder, _AliasFinder) for finder in _sys.meta_path):
|
|
34
|
+
_sys.meta_path.insert(0, _AliasFinder())
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Background task execution infrastructure.
|
|
3
|
+
|
|
4
|
+
Provides functionality to run CLI commands in detached background processes
|
|
5
|
+
with output logging and status tracking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .task_state import (
|
|
15
|
+
BackgroundTask,
|
|
16
|
+
TaskStatus,
|
|
17
|
+
add_task,
|
|
18
|
+
get_logs_dir,
|
|
19
|
+
get_task_by_id,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Log file format
|
|
23
|
+
LOG_FILE_FORMAT = "{command}_{timestamp}.log"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_log_file_path(command: str) -> Path:
|
|
27
|
+
"""
|
|
28
|
+
Create a unique log file path for a command.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
command: The command name (e.g., "agdt-git-save-work")
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Path to the new log file
|
|
35
|
+
"""
|
|
36
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
|
|
37
|
+
# Sanitize command name for filename
|
|
38
|
+
safe_command = command.replace("-", "_").replace(" ", "_")
|
|
39
|
+
filename = LOG_FILE_FORMAT.format(command=safe_command, timestamp=timestamp)
|
|
40
|
+
return get_logs_dir() / filename
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_python_executable() -> str:
|
|
44
|
+
"""Get the current Python executable path."""
|
|
45
|
+
return sys.executable
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_runner_script(
|
|
49
|
+
command: str,
|
|
50
|
+
task_id: str,
|
|
51
|
+
log_file: Path,
|
|
52
|
+
cwd: Optional[str] = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Build a Python script that runs the command and updates task state.
|
|
56
|
+
|
|
57
|
+
This script is executed in the background subprocess and handles:
|
|
58
|
+
- Redirecting output to log file
|
|
59
|
+
- Updating task status in state
|
|
60
|
+
- Capturing exit codes
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
command: The CLI command to run
|
|
64
|
+
task_id: The task ID for state updates
|
|
65
|
+
log_file: Path to write logs to
|
|
66
|
+
cwd: Working directory for the command
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Python script as a string
|
|
70
|
+
"""
|
|
71
|
+
# Get the path to this package for imports
|
|
72
|
+
package_dir = Path(__file__).parent.parent
|
|
73
|
+
|
|
74
|
+
script = f"""
|
|
75
|
+
import json
|
|
76
|
+
import os
|
|
77
|
+
import subprocess
|
|
78
|
+
import sys
|
|
79
|
+
from datetime import datetime, timezone
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
|
|
82
|
+
# Add package to path for imports
|
|
83
|
+
sys.path.insert(0, {repr(str(package_dir))})
|
|
84
|
+
|
|
85
|
+
from agentic_devtools.task_state import (
|
|
86
|
+
get_task_by_id,
|
|
87
|
+
update_task,
|
|
88
|
+
TaskStatus,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
task_id = {repr(task_id)}
|
|
93
|
+
log_file = Path({repr(str(log_file))})
|
|
94
|
+
command = {repr(command)}
|
|
95
|
+
cwd = {repr(cwd)}
|
|
96
|
+
|
|
97
|
+
# Ensure log directory exists
|
|
98
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
# Open log file for writing
|
|
101
|
+
with open(log_file, "w", encoding="utf-8") as log:
|
|
102
|
+
# Write header
|
|
103
|
+
start_time = datetime.now(timezone.utc).isoformat()
|
|
104
|
+
log.write(f"=== Task {{task_id}} ===\\n")
|
|
105
|
+
log.write(f"Command: {{command}}\\n")
|
|
106
|
+
log.write(f"Started: {{start_time}}\\n")
|
|
107
|
+
log.write(f"Working Directory: {{cwd or os.getcwd()}}\\n")
|
|
108
|
+
log.write("=" * 50 + "\\n\\n")
|
|
109
|
+
log.flush()
|
|
110
|
+
|
|
111
|
+
# Update task to running status
|
|
112
|
+
task = get_task_by_id(task_id)
|
|
113
|
+
if task:
|
|
114
|
+
task.mark_running()
|
|
115
|
+
update_task(task)
|
|
116
|
+
|
|
117
|
+
# Run the command with UTF-8 encoding for child process stdout
|
|
118
|
+
env = os.environ.copy()
|
|
119
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
command,
|
|
123
|
+
shell=True,
|
|
124
|
+
stdout=log,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
cwd=cwd,
|
|
127
|
+
text=True,
|
|
128
|
+
encoding="utf-8",
|
|
129
|
+
errors="replace",
|
|
130
|
+
env=env,
|
|
131
|
+
)
|
|
132
|
+
exit_code = result.returncode
|
|
133
|
+
error_message = None
|
|
134
|
+
except Exception as e:
|
|
135
|
+
exit_code = 1
|
|
136
|
+
error_message = str(e)
|
|
137
|
+
log.write(f"\\n\\n!!! Exception: {{error_message}}\\n")
|
|
138
|
+
|
|
139
|
+
# Write footer
|
|
140
|
+
end_time = datetime.now(timezone.utc).isoformat()
|
|
141
|
+
log.write("\\n" + "=" * 50 + "\\n")
|
|
142
|
+
log.write(f"Completed: {{end_time}}\\n")
|
|
143
|
+
log.write(f"Exit Code: {{exit_code}}\\n")
|
|
144
|
+
log.flush()
|
|
145
|
+
|
|
146
|
+
# Update task to completed/failed status
|
|
147
|
+
task = get_task_by_id(task_id)
|
|
148
|
+
if task:
|
|
149
|
+
if exit_code == 0:
|
|
150
|
+
task.mark_completed(exit_code)
|
|
151
|
+
else:
|
|
152
|
+
task.mark_failed(exit_code, error_message)
|
|
153
|
+
update_task(task)
|
|
154
|
+
|
|
155
|
+
sys.exit(exit_code)
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
|
159
|
+
"""
|
|
160
|
+
return script
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_in_background(
|
|
164
|
+
command: str,
|
|
165
|
+
args: Optional[Dict[str, Any]] = None,
|
|
166
|
+
cwd: Optional[str] = None,
|
|
167
|
+
) -> BackgroundTask:
|
|
168
|
+
"""
|
|
169
|
+
Run a CLI command in a detached background process.
|
|
170
|
+
|
|
171
|
+
The command runs in a separate process that:
|
|
172
|
+
- Continues running even if the parent process exits
|
|
173
|
+
- Writes all output to a log file
|
|
174
|
+
- Updates task state with progress and completion
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
command: The CLI command to run (e.g., "agdt-git-save-work")
|
|
178
|
+
args: Additional arguments/context to store with the task
|
|
179
|
+
cwd: Working directory for the command
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
BackgroundTask object representing the spawned task
|
|
183
|
+
"""
|
|
184
|
+
# Create log file path
|
|
185
|
+
log_file = create_log_file_path(command)
|
|
186
|
+
|
|
187
|
+
# Create task record
|
|
188
|
+
task = BackgroundTask.create(
|
|
189
|
+
command=command,
|
|
190
|
+
log_file=log_file,
|
|
191
|
+
args=args,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Add task to state
|
|
195
|
+
add_task(task)
|
|
196
|
+
|
|
197
|
+
# Build runner script
|
|
198
|
+
runner_script = _build_runner_script(
|
|
199
|
+
command=command,
|
|
200
|
+
task_id=task.id,
|
|
201
|
+
log_file=log_file,
|
|
202
|
+
cwd=cwd,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Get Python executable
|
|
206
|
+
python_exe = _get_python_executable()
|
|
207
|
+
|
|
208
|
+
# Set up environment with UTF-8 encoding for child process
|
|
209
|
+
import os as _os
|
|
210
|
+
|
|
211
|
+
env = _os.environ.copy()
|
|
212
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
213
|
+
|
|
214
|
+
# Launch detached process
|
|
215
|
+
if sys.platform == "win32":
|
|
216
|
+
# Windows: Use CREATE_NO_WINDOW to prevent console windows from opening
|
|
217
|
+
# Combined with CREATE_NEW_PROCESS_GROUP for proper signal handling
|
|
218
|
+
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
219
|
+
subprocess.Popen(
|
|
220
|
+
[python_exe, "-c", runner_script],
|
|
221
|
+
creationflags=creation_flags,
|
|
222
|
+
stdout=subprocess.DEVNULL,
|
|
223
|
+
stderr=subprocess.DEVNULL,
|
|
224
|
+
stdin=subprocess.DEVNULL,
|
|
225
|
+
close_fds=True,
|
|
226
|
+
cwd=cwd,
|
|
227
|
+
env=env,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
# Unix: Use double-fork or nohup-like behavior
|
|
231
|
+
subprocess.Popen(
|
|
232
|
+
[python_exe, "-c", runner_script],
|
|
233
|
+
stdout=subprocess.DEVNULL,
|
|
234
|
+
stderr=subprocess.DEVNULL,
|
|
235
|
+
stdin=subprocess.DEVNULL,
|
|
236
|
+
start_new_session=True,
|
|
237
|
+
close_fds=True,
|
|
238
|
+
cwd=cwd,
|
|
239
|
+
env=env,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return task
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_function_runner_script(
|
|
246
|
+
module_path: str,
|
|
247
|
+
function_name: str,
|
|
248
|
+
task_id: str,
|
|
249
|
+
log_file: Path,
|
|
250
|
+
cwd: Optional[str] = None,
|
|
251
|
+
) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Build a Python script that imports and runs a function, updating task state.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
module_path: Full module path (e.g., "agentic_devtools.cli.jira.comment_commands")
|
|
257
|
+
function_name: Name of the function to call (e.g., "add_comment")
|
|
258
|
+
task_id: The task ID for state updates
|
|
259
|
+
log_file: Path to write logs to
|
|
260
|
+
cwd: Working directory for the function
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Python script as a string
|
|
264
|
+
"""
|
|
265
|
+
package_dir = Path(__file__).parent.parent
|
|
266
|
+
|
|
267
|
+
script = f"""
|
|
268
|
+
import io
|
|
269
|
+
import os
|
|
270
|
+
import sys
|
|
271
|
+
import traceback
|
|
272
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
273
|
+
from datetime import datetime, timezone
|
|
274
|
+
from pathlib import Path
|
|
275
|
+
|
|
276
|
+
# Add package to path for imports
|
|
277
|
+
sys.path.insert(0, {repr(str(package_dir))})
|
|
278
|
+
|
|
279
|
+
from agentic_devtools.task_state import (
|
|
280
|
+
get_task_by_id,
|
|
281
|
+
update_task,
|
|
282
|
+
TaskStatus,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def main():
|
|
286
|
+
task_id = {repr(task_id)}
|
|
287
|
+
log_file = Path({repr(str(log_file))})
|
|
288
|
+
module_path = {repr(module_path)}
|
|
289
|
+
function_name = {repr(function_name)}
|
|
290
|
+
cwd = {repr(cwd)}
|
|
291
|
+
|
|
292
|
+
# Change to working directory if specified
|
|
293
|
+
if cwd:
|
|
294
|
+
os.chdir(cwd)
|
|
295
|
+
|
|
296
|
+
# Ensure log directory exists
|
|
297
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
# Open log file for writing
|
|
300
|
+
with open(log_file, "w", encoding="utf-8") as log:
|
|
301
|
+
# Write header
|
|
302
|
+
start_time = datetime.now(timezone.utc).isoformat()
|
|
303
|
+
log.write(f"=== Task {{task_id}} ===\\n")
|
|
304
|
+
log.write(f"Function: {{module_path}}.{{function_name}}\\n")
|
|
305
|
+
log.write(f"Started: {{start_time}}\\n")
|
|
306
|
+
log.write(f"Working Directory: {{os.getcwd()}}\\n")
|
|
307
|
+
log.write("=" * 50 + "\\n\\n")
|
|
308
|
+
log.flush()
|
|
309
|
+
|
|
310
|
+
# Update task to running status
|
|
311
|
+
task = get_task_by_id(task_id)
|
|
312
|
+
if task:
|
|
313
|
+
task.mark_running()
|
|
314
|
+
update_task(task)
|
|
315
|
+
|
|
316
|
+
# Capture stdout/stderr to log
|
|
317
|
+
exit_code = 0
|
|
318
|
+
error_message = None
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
# Import the module and get the function
|
|
322
|
+
import importlib
|
|
323
|
+
module = importlib.import_module(module_path)
|
|
324
|
+
func = getattr(module, function_name)
|
|
325
|
+
|
|
326
|
+
# Redirect stdout/stderr to log file
|
|
327
|
+
# Create a writer that writes to both the log and flushes
|
|
328
|
+
class LogWriter:
|
|
329
|
+
def __init__(self, log_file):
|
|
330
|
+
self.log = log_file
|
|
331
|
+
def write(self, text):
|
|
332
|
+
self.log.write(text)
|
|
333
|
+
self.log.flush()
|
|
334
|
+
def flush(self):
|
|
335
|
+
self.log.flush()
|
|
336
|
+
|
|
337
|
+
log_writer = LogWriter(log)
|
|
338
|
+
|
|
339
|
+
with redirect_stdout(log_writer), redirect_stderr(log_writer):
|
|
340
|
+
# Call the function
|
|
341
|
+
result = func()
|
|
342
|
+
# If function returns an int, use as exit code
|
|
343
|
+
if isinstance(result, int):
|
|
344
|
+
exit_code = result
|
|
345
|
+
|
|
346
|
+
except SystemExit as e:
|
|
347
|
+
# Function called sys.exit()
|
|
348
|
+
exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
exit_code = 1
|
|
351
|
+
error_message = str(e)
|
|
352
|
+
log.write(f"\\n\\n!!! Exception: {{error_message}}\\n")
|
|
353
|
+
log.write(traceback.format_exc())
|
|
354
|
+
|
|
355
|
+
# Write footer
|
|
356
|
+
end_time = datetime.now(timezone.utc).isoformat()
|
|
357
|
+
log.write("\\n" + "=" * 50 + "\\n")
|
|
358
|
+
log.write(f"Completed: {{end_time}}\\n")
|
|
359
|
+
log.write(f"Exit Code: {{exit_code}}\\n")
|
|
360
|
+
log.flush()
|
|
361
|
+
|
|
362
|
+
# Update task to completed/failed status
|
|
363
|
+
task = get_task_by_id(task_id)
|
|
364
|
+
if task:
|
|
365
|
+
if exit_code == 0:
|
|
366
|
+
task.mark_completed(exit_code)
|
|
367
|
+
else:
|
|
368
|
+
task.mark_failed(exit_code, error_message)
|
|
369
|
+
update_task(task)
|
|
370
|
+
|
|
371
|
+
sys.exit(exit_code)
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
main()
|
|
375
|
+
"""
|
|
376
|
+
return script
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def run_function_in_background(
|
|
380
|
+
module_path: str,
|
|
381
|
+
function_name: str,
|
|
382
|
+
command_display_name: Optional[str] = None,
|
|
383
|
+
args: Optional[Dict[str, Any]] = None,
|
|
384
|
+
cwd: Optional[str] = None,
|
|
385
|
+
) -> BackgroundTask:
|
|
386
|
+
"""
|
|
387
|
+
Run a Python function in a detached background process.
|
|
388
|
+
|
|
389
|
+
The function is imported and called directly, with stdout/stderr
|
|
390
|
+
captured to a log file.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
module_path: Full module path (e.g., "agentic_devtools.cli.jira.comment_commands")
|
|
394
|
+
function_name: Name of the function to call (e.g., "add_comment")
|
|
395
|
+
command_display_name: Human-readable name for the task (defaults to function_name)
|
|
396
|
+
args: Additional arguments/context to store with the task
|
|
397
|
+
cwd: Working directory for the function
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
BackgroundTask object representing the spawned task
|
|
401
|
+
|
|
402
|
+
Example:
|
|
403
|
+
task = run_function_in_background(
|
|
404
|
+
"agentic_devtools.cli.jira.comment_commands",
|
|
405
|
+
"add_comment",
|
|
406
|
+
command_display_name="agdt-add-jira-comment"
|
|
407
|
+
)
|
|
408
|
+
"""
|
|
409
|
+
display_name = command_display_name or function_name
|
|
410
|
+
|
|
411
|
+
# Create log file path
|
|
412
|
+
log_file = create_log_file_path(display_name)
|
|
413
|
+
|
|
414
|
+
# Create task record
|
|
415
|
+
task = BackgroundTask.create(
|
|
416
|
+
command=display_name,
|
|
417
|
+
log_file=log_file,
|
|
418
|
+
args=args,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Add task to state
|
|
422
|
+
add_task(task)
|
|
423
|
+
|
|
424
|
+
# Build runner script
|
|
425
|
+
runner_script = _build_function_runner_script(
|
|
426
|
+
module_path=module_path,
|
|
427
|
+
function_name=function_name,
|
|
428
|
+
task_id=task.id,
|
|
429
|
+
log_file=log_file,
|
|
430
|
+
cwd=cwd,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Get Python executable
|
|
434
|
+
python_exe = _get_python_executable()
|
|
435
|
+
|
|
436
|
+
# Set up environment with UTF-8 encoding for child process
|
|
437
|
+
import os as _os
|
|
438
|
+
|
|
439
|
+
env = _os.environ.copy()
|
|
440
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
441
|
+
|
|
442
|
+
# Launch detached process
|
|
443
|
+
if sys.platform == "win32":
|
|
444
|
+
# Windows: Use CREATE_NO_WINDOW to prevent console windows from opening
|
|
445
|
+
# Combined with CREATE_NEW_PROCESS_GROUP for proper signal handling
|
|
446
|
+
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
447
|
+
subprocess.Popen(
|
|
448
|
+
[python_exe, "-c", runner_script],
|
|
449
|
+
creationflags=creation_flags,
|
|
450
|
+
stdout=subprocess.DEVNULL,
|
|
451
|
+
stderr=subprocess.DEVNULL,
|
|
452
|
+
stdin=subprocess.DEVNULL,
|
|
453
|
+
close_fds=True,
|
|
454
|
+
cwd=cwd,
|
|
455
|
+
env=env,
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
subprocess.Popen(
|
|
459
|
+
[python_exe, "-c", runner_script],
|
|
460
|
+
stdout=subprocess.DEVNULL,
|
|
461
|
+
stderr=subprocess.DEVNULL,
|
|
462
|
+
stdin=subprocess.DEVNULL,
|
|
463
|
+
start_new_session=True,
|
|
464
|
+
close_fds=True,
|
|
465
|
+
cwd=cwd,
|
|
466
|
+
env=env,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return task
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def wait_for_task(
|
|
473
|
+
task_id: str,
|
|
474
|
+
poll_interval: float = 0.5,
|
|
475
|
+
timeout: Optional[float] = None,
|
|
476
|
+
) -> Tuple[bool, Optional[int]]:
|
|
477
|
+
"""
|
|
478
|
+
Wait for a background task to complete.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
task_id: ID of the task to wait for
|
|
482
|
+
poll_interval: Seconds between status checks
|
|
483
|
+
timeout: Maximum seconds to wait (None for infinite)
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Tuple of (success, exit_code):
|
|
487
|
+
- success: True if task completed successfully
|
|
488
|
+
- exit_code: The task's exit code (None if timeout or not found)
|
|
489
|
+
"""
|
|
490
|
+
import time
|
|
491
|
+
|
|
492
|
+
start_time = time.time()
|
|
493
|
+
|
|
494
|
+
while True:
|
|
495
|
+
task = get_task_by_id(task_id)
|
|
496
|
+
|
|
497
|
+
if task is None:
|
|
498
|
+
return (False, None)
|
|
499
|
+
|
|
500
|
+
if task.is_terminal():
|
|
501
|
+
return (task.status == TaskStatus.COMPLETED, task.exit_code)
|
|
502
|
+
|
|
503
|
+
if timeout is not None and (time.time() - start_time) > timeout:
|
|
504
|
+
return (False, None)
|
|
505
|
+
|
|
506
|
+
time.sleep(poll_interval)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def get_task_log_content(task_id: str, tail_lines: Optional[int] = None) -> Optional[str]:
|
|
510
|
+
"""
|
|
511
|
+
Get the content of a task's log file.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
task_id: ID of the task
|
|
515
|
+
tail_lines: If specified, only return the last N lines
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Log file content as string, or None if task/log not found
|
|
519
|
+
"""
|
|
520
|
+
task = get_task_by_id(task_id)
|
|
521
|
+
|
|
522
|
+
if task is None or task.log_file is None:
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
log_path = Path(task.log_file)
|
|
526
|
+
|
|
527
|
+
if not log_path.exists():
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
content = log_path.read_text(encoding="utf-8", errors="replace")
|
|
532
|
+
|
|
533
|
+
if tail_lines is not None:
|
|
534
|
+
lines = content.splitlines()
|
|
535
|
+
content = "\n".join(lines[-tail_lines:])
|
|
536
|
+
|
|
537
|
+
return content
|
|
538
|
+
except OSError:
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cleanup_old_logs(max_age_hours: float = 24, max_count: Optional[int] = None) -> int:
|
|
543
|
+
"""
|
|
544
|
+
Clean up old log files.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
max_age_hours: Delete logs older than this many hours
|
|
548
|
+
max_count: Keep at most this many log files (oldest deleted first)
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Number of log files deleted
|
|
552
|
+
"""
|
|
553
|
+
from datetime import timedelta
|
|
554
|
+
|
|
555
|
+
logs_dir = get_logs_dir()
|
|
556
|
+
deleted_count = 0
|
|
557
|
+
|
|
558
|
+
if not logs_dir.exists():
|
|
559
|
+
return 0
|
|
560
|
+
|
|
561
|
+
# Get all log files with their modification times
|
|
562
|
+
log_files: List[Tuple[Path, float]] = []
|
|
563
|
+
for log_file in logs_dir.glob("*.log"):
|
|
564
|
+
try:
|
|
565
|
+
mtime = log_file.stat().st_mtime
|
|
566
|
+
log_files.append((log_file, mtime))
|
|
567
|
+
except OSError:
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Sort by modification time (oldest first)
|
|
571
|
+
log_files.sort(key=lambda x: x[1])
|
|
572
|
+
|
|
573
|
+
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
|
|
574
|
+
cutoff_timestamp = cutoff_time.timestamp()
|
|
575
|
+
|
|
576
|
+
# Delete old files
|
|
577
|
+
for log_file, mtime in log_files:
|
|
578
|
+
if mtime < cutoff_timestamp:
|
|
579
|
+
try:
|
|
580
|
+
log_file.unlink()
|
|
581
|
+
deleted_count += 1
|
|
582
|
+
except OSError:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
# Delete excess files if max_count specified
|
|
586
|
+
if max_count is not None:
|
|
587
|
+
remaining = [f for f in logs_dir.glob("*.log")]
|
|
588
|
+
remaining.sort(key=lambda x: x.stat().st_mtime)
|
|
589
|
+
|
|
590
|
+
while len(remaining) > max_count:
|
|
591
|
+
oldest = remaining.pop(0)
|
|
592
|
+
try:
|
|
593
|
+
oldest.unlink()
|
|
594
|
+
deleted_count += 1
|
|
595
|
+
except OSError:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
return deleted_count
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI modules for agentic-devtools."""
|