galangal-orchestrate 0.13.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.
- galangal/__init__.py +36 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +167 -0
- galangal/ai/base.py +159 -0
- galangal/ai/claude.py +352 -0
- galangal/ai/codex.py +370 -0
- galangal/ai/gemini.py +43 -0
- galangal/ai/subprocess.py +254 -0
- galangal/cli.py +371 -0
- galangal/commands/__init__.py +27 -0
- galangal/commands/complete.py +367 -0
- galangal/commands/github.py +355 -0
- galangal/commands/init.py +177 -0
- galangal/commands/init_wizard.py +762 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +34 -0
- galangal/commands/prompts.py +89 -0
- galangal/commands/reset.py +41 -0
- galangal/commands/resume.py +30 -0
- galangal/commands/skip.py +62 -0
- galangal/commands/start.py +530 -0
- galangal/commands/status.py +44 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +15 -0
- galangal/config/defaults.py +183 -0
- galangal/config/loader.py +163 -0
- galangal/config/schema.py +330 -0
- galangal/core/__init__.py +33 -0
- galangal/core/artifacts.py +136 -0
- galangal/core/state.py +1097 -0
- galangal/core/tasks.py +454 -0
- galangal/core/utils.py +116 -0
- galangal/core/workflow/__init__.py +68 -0
- galangal/core/workflow/core.py +789 -0
- galangal/core/workflow/engine.py +781 -0
- galangal/core/workflow/pause.py +35 -0
- galangal/core/workflow/tui_runner.py +1322 -0
- galangal/exceptions.py +36 -0
- galangal/github/__init__.py +31 -0
- galangal/github/client.py +427 -0
- galangal/github/images.py +324 -0
- galangal/github/issues.py +298 -0
- galangal/logging.py +364 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +527 -0
- galangal/prompts/defaults/benchmark.md +34 -0
- galangal/prompts/defaults/contract.md +35 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +89 -0
- galangal/prompts/defaults/docs.md +104 -0
- galangal/prompts/defaults/migration.md +59 -0
- galangal/prompts/defaults/pm.md +110 -0
- galangal/prompts/defaults/pm_questions.md +53 -0
- galangal/prompts/defaults/preflight.md +32 -0
- galangal/prompts/defaults/qa.md +65 -0
- galangal/prompts/defaults/review.md +90 -0
- galangal/prompts/defaults/review_codex.md +99 -0
- galangal/prompts/defaults/security.md +84 -0
- galangal/prompts/defaults/test.md +91 -0
- galangal/results.py +176 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +126 -0
- galangal/ui/tui/__init__.py +56 -0
- galangal/ui/tui/adapters.py +168 -0
- galangal/ui/tui/app.py +902 -0
- galangal/ui/tui/entry.py +24 -0
- galangal/ui/tui/mixins.py +196 -0
- galangal/ui/tui/modals.py +339 -0
- galangal/ui/tui/styles/app.tcss +86 -0
- galangal/ui/tui/styles/modals.tcss +197 -0
- galangal/ui/tui/types.py +107 -0
- galangal/ui/tui/widgets.py +263 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +1072 -0
- galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
- galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
- galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.13.0.dist-info/licenses/LICENSE +674 -0
galangal/core/tasks.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task directory management - creating, listing, and switching tasks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from galangal.config.loader import (
|
|
15
|
+
get_active_file,
|
|
16
|
+
get_config,
|
|
17
|
+
get_done_dir,
|
|
18
|
+
get_project_root,
|
|
19
|
+
get_tasks_dir,
|
|
20
|
+
)
|
|
21
|
+
from galangal.core.artifacts import run_command
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from galangal.core.state import WorkflowState
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_active_task() -> str | None:
|
|
28
|
+
"""Get the currently active task name."""
|
|
29
|
+
active_file = get_active_file()
|
|
30
|
+
if active_file.exists():
|
|
31
|
+
return active_file.read_text().strip()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_active_task(task_name: str) -> None:
|
|
36
|
+
"""Set the active task."""
|
|
37
|
+
tasks_dir = get_tasks_dir()
|
|
38
|
+
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
get_active_file().write_text(task_name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clear_active_task() -> None:
|
|
43
|
+
"""Clear the active task."""
|
|
44
|
+
active_file = get_active_file()
|
|
45
|
+
if active_file.exists():
|
|
46
|
+
active_file.unlink()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_tasks() -> list[tuple[str, str, str, str]]:
|
|
50
|
+
"""List all tasks. Returns [(name, stage, task_type, description), ...]."""
|
|
51
|
+
tasks = []
|
|
52
|
+
tasks_dir = get_tasks_dir()
|
|
53
|
+
if not tasks_dir.exists():
|
|
54
|
+
return tasks
|
|
55
|
+
|
|
56
|
+
for task_dir in tasks_dir.iterdir():
|
|
57
|
+
if task_dir.is_dir() and not task_dir.name.startswith(".") and task_dir.name != "done":
|
|
58
|
+
state_file = task_dir / "state.json"
|
|
59
|
+
if state_file.exists():
|
|
60
|
+
try:
|
|
61
|
+
with open(state_file) as f:
|
|
62
|
+
data = json.load(f)
|
|
63
|
+
tasks.append(
|
|
64
|
+
(
|
|
65
|
+
task_dir.name,
|
|
66
|
+
data.get("stage", "?"),
|
|
67
|
+
data.get("task_type", "feature"),
|
|
68
|
+
data.get("task_description", "")[:50],
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
except (json.JSONDecodeError, KeyError):
|
|
72
|
+
tasks.append((task_dir.name, "?", "?", "(invalid state)"))
|
|
73
|
+
return sorted(tasks)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generate_task_name_ai(description: str) -> str | None:
|
|
77
|
+
"""Use AI to generate a concise, meaningful task name.
|
|
78
|
+
|
|
79
|
+
Uses the configured AI backend from config.ai.default with fallback support.
|
|
80
|
+
"""
|
|
81
|
+
from galangal.ai import get_backend_with_fallback
|
|
82
|
+
|
|
83
|
+
prompt = f"""Generate a short task name for this description. Rules:
|
|
84
|
+
- 2-4 words, kebab-case (e.g., fix-auth-bug, add-user-export)
|
|
85
|
+
- No prefix, just the name itself
|
|
86
|
+
- Capture the essence of the task
|
|
87
|
+
- Use action verbs (fix, add, update, refactor, implement)
|
|
88
|
+
|
|
89
|
+
Description: {description}
|
|
90
|
+
|
|
91
|
+
Reply with ONLY the task name, nothing else."""
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
config = get_config()
|
|
95
|
+
backend = get_backend_with_fallback(config.ai.default, config=config)
|
|
96
|
+
result = backend.generate_text(prompt, timeout=30)
|
|
97
|
+
|
|
98
|
+
if result:
|
|
99
|
+
# Clean the response - extract just the task name
|
|
100
|
+
name = result.strip().lower()
|
|
101
|
+
# Remove any quotes, backticks, or extra text
|
|
102
|
+
name = re.sub(r"[`\"']", "", name)
|
|
103
|
+
# Take only first line if multiple
|
|
104
|
+
name = name.split("\n")[0].strip()
|
|
105
|
+
# Validate it looks like a task name (kebab-case, reasonable length)
|
|
106
|
+
if re.match(r"^[a-z][a-z0-9-]{2,40}$", name) and name.count("-") <= 5:
|
|
107
|
+
return name
|
|
108
|
+
except (ValueError, Exception):
|
|
109
|
+
# ValueError: no backend available; Exception: other errors
|
|
110
|
+
pass
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate_task_name_fallback(description: str) -> str:
|
|
115
|
+
"""Fallback: Generate task name from description using simple word extraction."""
|
|
116
|
+
words = description.lower().split()[:4]
|
|
117
|
+
cleaned = [re.sub(r"[^a-z0-9]", "", w) for w in words]
|
|
118
|
+
cleaned = [w for w in cleaned if w]
|
|
119
|
+
name = "-".join(cleaned)
|
|
120
|
+
return name if name else f"task-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_task_name(description: str) -> str:
|
|
124
|
+
"""Generate a task name from description using AI with fallback."""
|
|
125
|
+
# Try AI-generated name first
|
|
126
|
+
ai_name = generate_task_name_ai(description)
|
|
127
|
+
if ai_name:
|
|
128
|
+
return ai_name
|
|
129
|
+
|
|
130
|
+
# Fallback to simple extraction
|
|
131
|
+
return generate_task_name_fallback(description)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Safe pattern for task names: alphanumeric, hyphens, underscores
|
|
135
|
+
# Must start with letter/number, max 60 chars
|
|
136
|
+
TASK_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,59}$")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_valid_task_name(name: str) -> tuple[bool, str]:
|
|
140
|
+
"""
|
|
141
|
+
Validate a task name for safety and format.
|
|
142
|
+
|
|
143
|
+
Task names must:
|
|
144
|
+
- Start with a letter or number
|
|
145
|
+
- Contain only alphanumeric characters, hyphens, and underscores
|
|
146
|
+
- Be 1-60 characters long
|
|
147
|
+
|
|
148
|
+
This prevents shell injection via task names used in validation commands.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: The task name to validate.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Tuple of (is_valid, error_message). Error message is empty if valid.
|
|
155
|
+
"""
|
|
156
|
+
if not name:
|
|
157
|
+
return False, "Task name cannot be empty"
|
|
158
|
+
|
|
159
|
+
if len(name) > 60:
|
|
160
|
+
return False, "Task name must be 60 characters or less"
|
|
161
|
+
|
|
162
|
+
if not TASK_NAME_PATTERN.match(name):
|
|
163
|
+
return (
|
|
164
|
+
False,
|
|
165
|
+
"Task name must start with letter/number and contain only alphanumeric, hyphens, underscores",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return True, ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def task_name_exists(name: str) -> bool:
|
|
172
|
+
"""Check if task name exists in active or done folders."""
|
|
173
|
+
return (get_tasks_dir() / name).exists() or (get_done_dir() / name).exists()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def generate_unique_task_name(
|
|
177
|
+
description: str,
|
|
178
|
+
prefix: str | None = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Generate a unique task name with automatic suffix if needed.
|
|
181
|
+
|
|
182
|
+
Uses AI to generate a meaningful task name from the description,
|
|
183
|
+
then ensures uniqueness by appending a numeric suffix if the name
|
|
184
|
+
already exists.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
description: Task description to generate name from.
|
|
188
|
+
prefix: Optional prefix (e.g., "issue-123") to prepend to the name.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A unique task name that doesn't conflict with existing tasks.
|
|
192
|
+
"""
|
|
193
|
+
base_name = generate_task_name(description)
|
|
194
|
+
|
|
195
|
+
if prefix:
|
|
196
|
+
base_name = f"{prefix}-{base_name}"
|
|
197
|
+
|
|
198
|
+
# Find unique name with suffix if needed
|
|
199
|
+
final_name = base_name
|
|
200
|
+
suffix = 2
|
|
201
|
+
while task_name_exists(final_name):
|
|
202
|
+
final_name = f"{base_name}-{suffix}"
|
|
203
|
+
suffix += 1
|
|
204
|
+
|
|
205
|
+
return final_name
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def create_task_branch(task_name: str) -> tuple[bool, str]:
|
|
209
|
+
"""Create a git branch for the task."""
|
|
210
|
+
config = get_config()
|
|
211
|
+
branch_name = config.branch_pattern.format(task_name=task_name)
|
|
212
|
+
|
|
213
|
+
# Check if branch already exists
|
|
214
|
+
code, out, _ = run_command(["git", "branch", "--list", branch_name])
|
|
215
|
+
if out.strip():
|
|
216
|
+
# Branch exists - check it out to ensure we're on it
|
|
217
|
+
code, out, err = run_command(["git", "checkout", branch_name])
|
|
218
|
+
if code != 0:
|
|
219
|
+
return False, f"Branch {branch_name} exists but checkout failed: {err}"
|
|
220
|
+
return True, f"Checked out existing branch: {branch_name}"
|
|
221
|
+
|
|
222
|
+
# Create and checkout new branch
|
|
223
|
+
code, out, err = run_command(["git", "checkout", "-b", branch_name])
|
|
224
|
+
if code != 0:
|
|
225
|
+
return False, f"Failed to create branch: {err}"
|
|
226
|
+
|
|
227
|
+
return True, f"Created branch: {branch_name}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_current_branch() -> str:
|
|
231
|
+
"""Get the current git branch name."""
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
["git", "branch", "--show-current"],
|
|
235
|
+
cwd=get_project_root(),
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=5,
|
|
239
|
+
)
|
|
240
|
+
return result.stdout.strip() or "unknown"
|
|
241
|
+
except Exception:
|
|
242
|
+
return "unknown"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def is_on_base_branch() -> tuple[bool, str, str]:
|
|
246
|
+
"""
|
|
247
|
+
Check if the repo is on the configured base branch.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Tuple of (is_on_base, current_branch, base_branch)
|
|
251
|
+
"""
|
|
252
|
+
config = get_config()
|
|
253
|
+
base_branch = config.pr.base_branch
|
|
254
|
+
current_branch = get_current_branch()
|
|
255
|
+
return current_branch == base_branch, current_branch, base_branch
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def switch_to_base_branch() -> tuple[bool, str]:
|
|
259
|
+
"""
|
|
260
|
+
Switch to the configured base branch.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Tuple of (success, message)
|
|
264
|
+
"""
|
|
265
|
+
config = get_config()
|
|
266
|
+
base_branch = config.pr.base_branch
|
|
267
|
+
|
|
268
|
+
code, out, err = run_command(["git", "checkout", base_branch])
|
|
269
|
+
if code != 0:
|
|
270
|
+
return False, f"Failed to switch to {base_branch}: {err}"
|
|
271
|
+
|
|
272
|
+
return True, f"Switched to {base_branch}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pull_base_branch() -> tuple[bool, str]:
|
|
276
|
+
"""
|
|
277
|
+
Pull the latest changes from the remote base branch.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Tuple of (success, message)
|
|
281
|
+
"""
|
|
282
|
+
config = get_config()
|
|
283
|
+
base_branch = config.pr.base_branch
|
|
284
|
+
|
|
285
|
+
code, out, err = run_command(["git", "pull", "origin", base_branch])
|
|
286
|
+
if code != 0:
|
|
287
|
+
# Check if it's a "no tracking" error - try without remote name
|
|
288
|
+
if "no tracking information" in err.lower():
|
|
289
|
+
code, out, err = run_command(["git", "pull"])
|
|
290
|
+
if code != 0:
|
|
291
|
+
return False, f"Failed to pull: {err}"
|
|
292
|
+
else:
|
|
293
|
+
return False, f"Failed to pull from origin/{base_branch}: {err}"
|
|
294
|
+
|
|
295
|
+
return True, f"Pulled latest from {base_branch}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ensure_active_task_with_state(
|
|
299
|
+
no_task_msg: str = "No active task.",
|
|
300
|
+
no_state_msg: str = "Could not load state for '{task}'.",
|
|
301
|
+
) -> tuple[str, WorkflowState] | tuple[None, None]:
|
|
302
|
+
"""Load active task and its state, with error handling.
|
|
303
|
+
|
|
304
|
+
This helper consolidates the common pattern of loading the active task
|
|
305
|
+
and its state, with appropriate error messages for each failure case.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
no_task_msg: Message to print if no active task.
|
|
309
|
+
no_state_msg: Message template if state can't be loaded.
|
|
310
|
+
Use {task} placeholder for task name.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Tuple of (task_name, state) if successful,
|
|
314
|
+
or (None, None) with error printed if failed.
|
|
315
|
+
"""
|
|
316
|
+
from galangal.core.state import load_state
|
|
317
|
+
from galangal.ui.console import print_error
|
|
318
|
+
|
|
319
|
+
active = get_active_task()
|
|
320
|
+
if not active:
|
|
321
|
+
print_error(no_task_msg)
|
|
322
|
+
return None, None
|
|
323
|
+
|
|
324
|
+
state = load_state(active)
|
|
325
|
+
if state is None:
|
|
326
|
+
print_error(no_state_msg.format(task=active))
|
|
327
|
+
return None, None
|
|
328
|
+
|
|
329
|
+
return active, state
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass
|
|
333
|
+
class TaskFromIssueResult:
|
|
334
|
+
"""Result of creating a task from a GitHub issue."""
|
|
335
|
+
|
|
336
|
+
success: bool
|
|
337
|
+
message: str
|
|
338
|
+
task_name: str | None = None
|
|
339
|
+
screenshots: list[str] | None = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def create_task_from_issue(
|
|
343
|
+
issue: GitHubIssue,
|
|
344
|
+
repo_name: str | None = None,
|
|
345
|
+
task_name_override: str | None = None,
|
|
346
|
+
mark_in_progress: bool = True,
|
|
347
|
+
) -> TaskFromIssueResult:
|
|
348
|
+
"""
|
|
349
|
+
Create a task from a GitHub issue with all associated setup.
|
|
350
|
+
|
|
351
|
+
This consolidates the task creation flow that was duplicated across
|
|
352
|
+
start.py, tui_runner.py, and github.py. Handles:
|
|
353
|
+
- Task name generation and validation
|
|
354
|
+
- Task creation with GitHub metadata
|
|
355
|
+
- Screenshot download (after task creation)
|
|
356
|
+
- Marking issue as in-progress
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
issue: The GitHubIssue to create a task from
|
|
360
|
+
repo_name: Optional repo name (owner/repo), fetched if not provided
|
|
361
|
+
task_name_override: Optional override for task name (will be validated)
|
|
362
|
+
mark_in_progress: Whether to mark the issue as in-progress
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
TaskFromIssueResult with success status, message, and task details
|
|
366
|
+
"""
|
|
367
|
+
from galangal.commands.start import create_task
|
|
368
|
+
from galangal.core.state import TaskType, get_task_dir, load_state, save_state
|
|
369
|
+
from galangal.github.issues import (
|
|
370
|
+
download_issue_screenshots,
|
|
371
|
+
mark_issue_in_progress,
|
|
372
|
+
prepare_issue_for_task,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Step 1: Extract issue data using existing helper
|
|
376
|
+
issue_data = prepare_issue_for_task(issue, repo_name)
|
|
377
|
+
|
|
378
|
+
# Step 2: Infer task type from labels
|
|
379
|
+
task_type = (
|
|
380
|
+
TaskType.from_str(issue_data.task_type_hint)
|
|
381
|
+
if issue_data.task_type_hint
|
|
382
|
+
else TaskType.FEATURE
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Step 3: Generate or validate task name
|
|
386
|
+
if task_name_override:
|
|
387
|
+
# Validate provided name
|
|
388
|
+
valid, error_msg = is_valid_task_name(task_name_override)
|
|
389
|
+
if not valid:
|
|
390
|
+
return TaskFromIssueResult(
|
|
391
|
+
success=False,
|
|
392
|
+
message=f"Invalid task name: {error_msg}",
|
|
393
|
+
)
|
|
394
|
+
if task_name_exists(task_name_override):
|
|
395
|
+
return TaskFromIssueResult(
|
|
396
|
+
success=False,
|
|
397
|
+
message=f"Task '{task_name_override}' already exists",
|
|
398
|
+
)
|
|
399
|
+
task_name = task_name_override
|
|
400
|
+
else:
|
|
401
|
+
# Generate unique name with issue prefix
|
|
402
|
+
prefix = f"issue-{issue.number}"
|
|
403
|
+
task_name = generate_unique_task_name(issue_data.description, prefix)
|
|
404
|
+
|
|
405
|
+
# Step 4: Create the task
|
|
406
|
+
success, message = create_task(
|
|
407
|
+
task_name,
|
|
408
|
+
issue_data.description,
|
|
409
|
+
task_type,
|
|
410
|
+
github_issue=issue.number,
|
|
411
|
+
github_repo=issue_data.github_repo,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if not success:
|
|
415
|
+
return TaskFromIssueResult(
|
|
416
|
+
success=False,
|
|
417
|
+
message=message,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Step 5: Download screenshots AFTER task creation
|
|
421
|
+
screenshots = []
|
|
422
|
+
if issue_data.issue_body:
|
|
423
|
+
try:
|
|
424
|
+
task_dir = get_task_dir(task_name)
|
|
425
|
+
screenshots = download_issue_screenshots(issue_data.issue_body, task_dir)
|
|
426
|
+
if screenshots:
|
|
427
|
+
# Update state with screenshot paths
|
|
428
|
+
state = load_state(task_name)
|
|
429
|
+
if state:
|
|
430
|
+
state.screenshots = screenshots
|
|
431
|
+
save_state(state)
|
|
432
|
+
except Exception:
|
|
433
|
+
# Non-critical - continue without screenshots
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
# Step 6: Mark issue as in-progress
|
|
437
|
+
if mark_in_progress:
|
|
438
|
+
try:
|
|
439
|
+
mark_issue_in_progress(issue.number)
|
|
440
|
+
except Exception:
|
|
441
|
+
# Non-critical - continue anyway
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
return TaskFromIssueResult(
|
|
445
|
+
success=True,
|
|
446
|
+
message=message,
|
|
447
|
+
task_name=task_name,
|
|
448
|
+
screenshots=screenshots,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# Type hint import for type checking only
|
|
453
|
+
if TYPE_CHECKING:
|
|
454
|
+
from galangal.github.issues import GitHubIssue
|
galangal/core/utils.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common utility functions to avoid code duplication.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Debug log file path (lazily initialized)
|
|
11
|
+
_debug_file: Path | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_debug_enabled() -> bool:
|
|
15
|
+
"""Check if debug mode is enabled.
|
|
16
|
+
|
|
17
|
+
Always checks the environment variable (no caching) to handle
|
|
18
|
+
cases where debug mode is enabled after initial import.
|
|
19
|
+
"""
|
|
20
|
+
return os.environ.get("GALANGAL_DEBUG", "").lower() in ("1", "true", "yes")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reset_debug_state() -> None:
|
|
24
|
+
"""Reset debug file path. Called when debug mode is enabled via CLI."""
|
|
25
|
+
global _debug_file
|
|
26
|
+
_debug_file = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def debug_log(message: str, **context: object) -> None:
|
|
30
|
+
"""Log a debug message if debug mode is enabled.
|
|
31
|
+
|
|
32
|
+
Writes to logs/galangal_debug.log with timestamp.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: The message to log.
|
|
36
|
+
**context: Additional key-value pairs to include in the log.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
debug_log("GitHub API call failed", error=str(e), issue=123)
|
|
40
|
+
"""
|
|
41
|
+
if not is_debug_enabled():
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
global _debug_file
|
|
45
|
+
if _debug_file is None:
|
|
46
|
+
from galangal.config.loader import get_project_root
|
|
47
|
+
|
|
48
|
+
logs_dir = get_project_root() / "logs"
|
|
49
|
+
logs_dir.mkdir(exist_ok=True)
|
|
50
|
+
_debug_file = logs_dir / "galangal_debug.log"
|
|
51
|
+
|
|
52
|
+
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
53
|
+
context_str = " ".join(f"{k}={v}" for k, v in context.items()) if context else ""
|
|
54
|
+
line = f"[{timestamp}] {message}"
|
|
55
|
+
if context_str:
|
|
56
|
+
line += f" | {context_str}"
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with open(_debug_file, "a") as f:
|
|
60
|
+
f.write(line + "\n")
|
|
61
|
+
except Exception:
|
|
62
|
+
pass # Don't fail if we can't write debug log
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def debug_exception(message: str, exc: Exception) -> None:
|
|
66
|
+
"""Log an exception with full traceback if debug mode is enabled.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
message: Context message about what failed.
|
|
70
|
+
exc: The exception that was caught.
|
|
71
|
+
"""
|
|
72
|
+
if not is_debug_enabled():
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
debug_log(f"{message}: {type(exc).__name__}: {exc}")
|
|
76
|
+
debug_log(f"Traceback:\n{traceback.format_exc()}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def now_iso() -> str:
|
|
80
|
+
"""Return current UTC datetime as ISO format string.
|
|
81
|
+
|
|
82
|
+
This is the canonical way to get timestamps throughout the codebase.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ISO format string, e.g., "2024-01-15T10:30:00+00:00"
|
|
86
|
+
"""
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def now_formatted() -> str:
|
|
91
|
+
"""Return current UTC datetime in human-readable format.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Formatted string, e.g., "2024-01-15 10:30 UTC"
|
|
95
|
+
"""
|
|
96
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def truncate_text(
|
|
100
|
+
text: str,
|
|
101
|
+
max_length: int = 1500,
|
|
102
|
+
suffix: str = "...",
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Truncate text to max_length, adding suffix if truncated.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
text: Text to truncate.
|
|
108
|
+
max_length: Maximum length before truncation.
|
|
109
|
+
suffix: String to append if truncated.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Original text if within limit, or truncated text with suffix.
|
|
113
|
+
"""
|
|
114
|
+
if len(text) <= max_length:
|
|
115
|
+
return text
|
|
116
|
+
return text[:max_length] + suffix
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow execution - stage execution, rollback, loop handling.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- run_workflow: Main entry point for workflow execution
|
|
6
|
+
- get_next_stage: Get the next stage in the workflow
|
|
7
|
+
- execute_stage: Execute a single stage
|
|
8
|
+
- handle_rollback: Handle rollback signals from validators
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from galangal.core.state import WorkflowState
|
|
12
|
+
from galangal.core.workflow.core import (
|
|
13
|
+
archive_rollback_if_exists,
|
|
14
|
+
execute_stage,
|
|
15
|
+
get_next_stage,
|
|
16
|
+
handle_rollback,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"run_workflow",
|
|
21
|
+
"get_next_stage",
|
|
22
|
+
"execute_stage",
|
|
23
|
+
"handle_rollback",
|
|
24
|
+
"archive_rollback_if_exists",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _init_logging() -> None:
|
|
29
|
+
"""Initialize structured logging from config."""
|
|
30
|
+
from galangal.config.loader import get_config
|
|
31
|
+
from galangal.logging import configure_logging
|
|
32
|
+
|
|
33
|
+
config = get_config()
|
|
34
|
+
log_config = config.logging
|
|
35
|
+
|
|
36
|
+
if log_config.enabled:
|
|
37
|
+
configure_logging(
|
|
38
|
+
level=log_config.level, # type: ignore
|
|
39
|
+
log_file=log_config.file,
|
|
40
|
+
json_format=log_config.json_format,
|
|
41
|
+
console_output=log_config.console,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_workflow(state: WorkflowState) -> None:
|
|
46
|
+
"""Run the workflow from current state to completion or failure."""
|
|
47
|
+
from galangal.core.workflow.tui_runner import _run_workflow_with_tui
|
|
48
|
+
from galangal.logging import workflow_logger
|
|
49
|
+
|
|
50
|
+
# Initialize logging if configured
|
|
51
|
+
_init_logging()
|
|
52
|
+
|
|
53
|
+
# Log workflow start
|
|
54
|
+
workflow_logger.workflow_started(
|
|
55
|
+
task_name=state.task_name,
|
|
56
|
+
task_type=state.task_type.value,
|
|
57
|
+
stage=state.stage.value,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
_run_workflow_with_tui(state)
|
|
62
|
+
finally:
|
|
63
|
+
# Log workflow end
|
|
64
|
+
workflow_logger.workflow_completed(
|
|
65
|
+
task_name=state.task_name,
|
|
66
|
+
task_type=state.task_type.value,
|
|
67
|
+
success=(state.stage.value == "COMPLETE"),
|
|
68
|
+
)
|