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
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal start - Start a new task.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
from galangal.core.state import (
|
|
9
|
+
TaskType,
|
|
10
|
+
WorkflowState,
|
|
11
|
+
get_task_dir,
|
|
12
|
+
load_state,
|
|
13
|
+
save_state,
|
|
14
|
+
)
|
|
15
|
+
from galangal.core.tasks import (
|
|
16
|
+
create_task_branch,
|
|
17
|
+
generate_unique_task_name,
|
|
18
|
+
is_on_base_branch,
|
|
19
|
+
is_valid_task_name,
|
|
20
|
+
pull_base_branch,
|
|
21
|
+
set_active_task,
|
|
22
|
+
switch_to_base_branch,
|
|
23
|
+
task_name_exists,
|
|
24
|
+
)
|
|
25
|
+
from galangal.core.utils import debug_exception, debug_log
|
|
26
|
+
from galangal.core.workflow import run_workflow
|
|
27
|
+
from galangal.ui.tui import PromptType, WorkflowTUIApp
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_config_updates() -> bool:
|
|
31
|
+
"""Check for missing config sections and prompt user.
|
|
32
|
+
|
|
33
|
+
Returns True if user wants to continue, False if they want to quit and configure.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
import yaml
|
|
37
|
+
from rich.prompt import Confirm
|
|
38
|
+
|
|
39
|
+
from galangal.commands.init_wizard import check_missing_sections
|
|
40
|
+
from galangal.config.loader import get_project_root
|
|
41
|
+
from galangal.ui.console import console, print_info
|
|
42
|
+
|
|
43
|
+
config_path = get_project_root() / ".galangal" / "config.yaml"
|
|
44
|
+
if not config_path.exists():
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
existing_config = yaml.safe_load(config_path.read_text())
|
|
48
|
+
if not existing_config:
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
missing = check_missing_sections(existing_config)
|
|
52
|
+
if not missing:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
# Show message about new config options
|
|
56
|
+
console.print()
|
|
57
|
+
print_info(f"New config options available: [cyan]{', '.join(missing)}[/cyan]")
|
|
58
|
+
console.print("[dim] Run 'galangal init' to configure them.[/dim]\n")
|
|
59
|
+
|
|
60
|
+
# Ask if they want to continue or quit to configure
|
|
61
|
+
continue_anyway = Confirm.ask(
|
|
62
|
+
"Continue without configuring?",
|
|
63
|
+
default=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if not continue_anyway:
|
|
67
|
+
console.print("\n[dim]Run 'galangal init' to configure new options.[/dim]")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
console.print() # Add spacing before TUI starts
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
except Exception:
|
|
74
|
+
# Non-critical, don't interrupt task creation
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_task(
|
|
79
|
+
task_name: str,
|
|
80
|
+
description: str,
|
|
81
|
+
task_type: TaskType,
|
|
82
|
+
github_issue: int | None = None,
|
|
83
|
+
github_repo: str | None = None,
|
|
84
|
+
screenshots: list[str] | None = None,
|
|
85
|
+
) -> tuple[bool, str]:
|
|
86
|
+
"""Create a new task with the given name, description, and type.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
task_name: Name for the task (will be used for directory and branch)
|
|
90
|
+
description: Task description
|
|
91
|
+
task_type: Type of task (Feature, Bug Fix, etc.)
|
|
92
|
+
github_issue: Optional GitHub issue number this task is linked to
|
|
93
|
+
github_repo: Optional GitHub repo (owner/repo) for the issue
|
|
94
|
+
screenshots: Optional list of local screenshot paths from the issue
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Tuple of (success, message)
|
|
98
|
+
"""
|
|
99
|
+
# Check if task already exists
|
|
100
|
+
if task_name_exists(task_name):
|
|
101
|
+
return False, f"Task '{task_name}' already exists"
|
|
102
|
+
|
|
103
|
+
task_dir = get_task_dir(task_name)
|
|
104
|
+
|
|
105
|
+
# Create git branch
|
|
106
|
+
success, msg = create_task_branch(task_name)
|
|
107
|
+
if not success and "already exists" not in msg.lower():
|
|
108
|
+
return False, msg
|
|
109
|
+
|
|
110
|
+
# Create task directory
|
|
111
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
(task_dir / "logs").mkdir(exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Initialize state with task type and optional GitHub info
|
|
115
|
+
state = WorkflowState.new(
|
|
116
|
+
description, task_name, task_type, github_issue, github_repo, screenshots
|
|
117
|
+
)
|
|
118
|
+
save_state(state)
|
|
119
|
+
|
|
120
|
+
# Set as active task
|
|
121
|
+
set_active_task(task_name)
|
|
122
|
+
|
|
123
|
+
return True, f"Created task: {task_name}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
127
|
+
"""Start a new task."""
|
|
128
|
+
from galangal.config.loader import require_initialized
|
|
129
|
+
|
|
130
|
+
if not require_initialized():
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
# Check for new/missing config sections - prompt user if any found
|
|
134
|
+
if not _check_config_updates():
|
|
135
|
+
return 0 # User chose to quit and configure
|
|
136
|
+
|
|
137
|
+
description = " ".join(args.description) if args.description else ""
|
|
138
|
+
task_name = args.name or ""
|
|
139
|
+
from_issue = getattr(args, "issue", None)
|
|
140
|
+
|
|
141
|
+
# Create TUI app for task setup
|
|
142
|
+
app = WorkflowTUIApp("New Task", "SETUP", hidden_stages=frozenset())
|
|
143
|
+
|
|
144
|
+
task_info = {
|
|
145
|
+
"type": None,
|
|
146
|
+
"description": description,
|
|
147
|
+
"name": task_name,
|
|
148
|
+
"github_issue": from_issue,
|
|
149
|
+
"github_repo": None,
|
|
150
|
+
"screenshots": None,
|
|
151
|
+
}
|
|
152
|
+
result_code = {"value": 0}
|
|
153
|
+
|
|
154
|
+
def task_creation_thread():
|
|
155
|
+
try:
|
|
156
|
+
app.add_activity("[bold]Starting new task...[/bold]", "🆕")
|
|
157
|
+
|
|
158
|
+
# Check if on base branch before starting
|
|
159
|
+
on_base, current_branch, base_branch = is_on_base_branch()
|
|
160
|
+
if not on_base:
|
|
161
|
+
app.set_status("setup", "checking branch")
|
|
162
|
+
app.add_activity(
|
|
163
|
+
f"Currently on branch '{current_branch}', expected '{base_branch}'",
|
|
164
|
+
"⚠️",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
branch_event = threading.Event()
|
|
168
|
+
branch_result = {"value": None}
|
|
169
|
+
|
|
170
|
+
def handle_branch_choice(choice):
|
|
171
|
+
branch_result["value"] = choice
|
|
172
|
+
branch_event.set()
|
|
173
|
+
|
|
174
|
+
app.show_prompt(
|
|
175
|
+
PromptType.YES_NO,
|
|
176
|
+
f"Switch to '{base_branch}' branch before creating task?",
|
|
177
|
+
handle_branch_choice,
|
|
178
|
+
)
|
|
179
|
+
branch_event.wait()
|
|
180
|
+
|
|
181
|
+
if branch_result["value"] == "yes":
|
|
182
|
+
success, message = switch_to_base_branch()
|
|
183
|
+
if success:
|
|
184
|
+
app.add_activity(f"Switched to '{base_branch}' branch", "✓")
|
|
185
|
+
# Pull latest changes after switching
|
|
186
|
+
app.set_status("setup", "pulling latest")
|
|
187
|
+
pull_success, pull_msg = pull_base_branch()
|
|
188
|
+
if pull_success:
|
|
189
|
+
app.add_activity(f"Pulled latest from '{base_branch}'", "✓")
|
|
190
|
+
else:
|
|
191
|
+
app.add_activity(f"Pull failed: {pull_msg}", "⚠️")
|
|
192
|
+
# Non-fatal - warn but continue
|
|
193
|
+
app.show_message(f"Warning: {pull_msg}", "warning")
|
|
194
|
+
else:
|
|
195
|
+
app.add_activity(f"Failed to switch branch: {message}", "✗")
|
|
196
|
+
app.show_message(
|
|
197
|
+
f"Could not switch to {base_branch}: {message}",
|
|
198
|
+
"error",
|
|
199
|
+
)
|
|
200
|
+
app._workflow_result = "error"
|
|
201
|
+
result_code["value"] = 1
|
|
202
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
203
|
+
return
|
|
204
|
+
else:
|
|
205
|
+
# User chose not to switch - continue on current branch
|
|
206
|
+
app.add_activity(
|
|
207
|
+
f"Continuing on '{current_branch}' branch",
|
|
208
|
+
"ℹ️",
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
# Already on base branch - pull latest changes
|
|
212
|
+
app.set_status("setup", "pulling latest")
|
|
213
|
+
pull_success, pull_msg = pull_base_branch()
|
|
214
|
+
if pull_success:
|
|
215
|
+
app.add_activity(f"Pulled latest from '{base_branch}'", "✓")
|
|
216
|
+
else:
|
|
217
|
+
app.add_activity(f"Pull failed: {pull_msg}", "⚠️")
|
|
218
|
+
# Non-fatal - warn but continue
|
|
219
|
+
app.show_message(f"Warning: {pull_msg}", "warning")
|
|
220
|
+
|
|
221
|
+
# Step 0: Choose task source (manual or GitHub) if no description/issue provided
|
|
222
|
+
if not task_info["description"] and not task_info["github_issue"]:
|
|
223
|
+
app.set_status("setup", "select task source")
|
|
224
|
+
|
|
225
|
+
source_event = threading.Event()
|
|
226
|
+
source_result = {"value": None}
|
|
227
|
+
|
|
228
|
+
def handle_source(choice):
|
|
229
|
+
source_result["value"] = choice
|
|
230
|
+
source_event.set()
|
|
231
|
+
|
|
232
|
+
app.show_prompt(
|
|
233
|
+
PromptType.TASK_SOURCE,
|
|
234
|
+
"Create task from:",
|
|
235
|
+
handle_source,
|
|
236
|
+
)
|
|
237
|
+
source_event.wait()
|
|
238
|
+
|
|
239
|
+
if source_result["value"] == "quit":
|
|
240
|
+
app._workflow_result = "cancelled"
|
|
241
|
+
result_code["value"] = 1
|
|
242
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
if source_result["value"] == "github":
|
|
246
|
+
# Handle GitHub issue selection
|
|
247
|
+
app.set_status("setup", "checking GitHub")
|
|
248
|
+
app.show_message("Checking GitHub setup...", "info")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
from galangal.github.client import ensure_github_ready
|
|
252
|
+
from galangal.github.issues import list_issues
|
|
253
|
+
|
|
254
|
+
check = ensure_github_ready()
|
|
255
|
+
if not check:
|
|
256
|
+
app.show_message(
|
|
257
|
+
"GitHub not ready. Run 'galangal github check'", "error"
|
|
258
|
+
)
|
|
259
|
+
app._workflow_result = "error"
|
|
260
|
+
result_code["value"] = 1
|
|
261
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
task_info["github_repo"] = check.repo_name
|
|
265
|
+
|
|
266
|
+
# List issues with galangal label
|
|
267
|
+
app.set_status("setup", "fetching issues")
|
|
268
|
+
app.show_message("Fetching issues...", "info")
|
|
269
|
+
|
|
270
|
+
issues = list_issues()
|
|
271
|
+
if not issues:
|
|
272
|
+
app.show_message("No issues with 'galangal' label found", "warning")
|
|
273
|
+
app._workflow_result = "cancelled"
|
|
274
|
+
result_code["value"] = 1
|
|
275
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Show issue selection
|
|
279
|
+
app.set_status("setup", "select issue")
|
|
280
|
+
issue_event = threading.Event()
|
|
281
|
+
issue_result = {"value": None}
|
|
282
|
+
|
|
283
|
+
def handle_issue(issue_num):
|
|
284
|
+
issue_result["value"] = issue_num
|
|
285
|
+
issue_event.set()
|
|
286
|
+
|
|
287
|
+
issue_options = [(i.number, i.title) for i in issues]
|
|
288
|
+
app.show_github_issue_select(issue_options, handle_issue)
|
|
289
|
+
issue_event.wait()
|
|
290
|
+
|
|
291
|
+
if issue_result["value"] is None:
|
|
292
|
+
app._workflow_result = "cancelled"
|
|
293
|
+
result_code["value"] = 1
|
|
294
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Get the selected issue details
|
|
298
|
+
selected_issue = next(
|
|
299
|
+
(i for i in issues if i.number == issue_result["value"]), None
|
|
300
|
+
)
|
|
301
|
+
if selected_issue:
|
|
302
|
+
task_info["github_issue"] = selected_issue.number
|
|
303
|
+
task_info["description"] = (
|
|
304
|
+
f"{selected_issue.title}\n\n{selected_issue.body}"
|
|
305
|
+
)
|
|
306
|
+
app.show_message(f"Selected issue #{selected_issue.number}", "success")
|
|
307
|
+
|
|
308
|
+
# Download screenshots from issue body
|
|
309
|
+
from galangal.github.images import extract_image_urls
|
|
310
|
+
|
|
311
|
+
images = extract_image_urls(selected_issue.body)
|
|
312
|
+
if images:
|
|
313
|
+
app.set_status("setup", "downloading screenshots")
|
|
314
|
+
app.show_message(
|
|
315
|
+
f"Found {len(images)} screenshot(s) in issue...", "info"
|
|
316
|
+
)
|
|
317
|
+
# Note: Actual download happens after task_name is generated
|
|
318
|
+
# Store the issue body for later processing
|
|
319
|
+
task_info["_issue_body"] = selected_issue.body
|
|
320
|
+
|
|
321
|
+
# Try to infer task type from labels
|
|
322
|
+
type_hint = selected_issue.get_task_type_hint()
|
|
323
|
+
if type_hint:
|
|
324
|
+
task_info["type"] = TaskType.from_str(type_hint)
|
|
325
|
+
app.show_message(
|
|
326
|
+
f"Inferred type from labels: {task_info['type'].display_name()}",
|
|
327
|
+
"info",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
except Exception as e:
|
|
331
|
+
debug_exception("GitHub integration failed", e)
|
|
332
|
+
app.show_message(f"GitHub error: {e}", "error")
|
|
333
|
+
app._workflow_result = "error"
|
|
334
|
+
result_code["value"] = 1
|
|
335
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
# Step 1: Get task type (if not already set from GitHub labels)
|
|
339
|
+
if task_info["type"] is None:
|
|
340
|
+
app.set_status("setup", "select task type")
|
|
341
|
+
|
|
342
|
+
type_event = threading.Event()
|
|
343
|
+
type_result = {"value": None}
|
|
344
|
+
|
|
345
|
+
def handle_type(choice):
|
|
346
|
+
type_result["value"] = choice
|
|
347
|
+
type_event.set()
|
|
348
|
+
|
|
349
|
+
app.show_prompt(
|
|
350
|
+
PromptType.TASK_TYPE,
|
|
351
|
+
"Select task type:",
|
|
352
|
+
handle_type,
|
|
353
|
+
)
|
|
354
|
+
type_event.wait()
|
|
355
|
+
|
|
356
|
+
if type_result["value"] == "quit":
|
|
357
|
+
app._workflow_result = "cancelled"
|
|
358
|
+
result_code["value"] = 1
|
|
359
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Map selection to TaskType
|
|
363
|
+
task_info["type"] = TaskType.from_str(type_result["value"])
|
|
364
|
+
|
|
365
|
+
app.show_message(f"Task type: {task_info['type'].display_name()}", "success")
|
|
366
|
+
|
|
367
|
+
# Step 2: Get task description if not provided
|
|
368
|
+
if not task_info["description"]:
|
|
369
|
+
app.set_status("setup", "enter description")
|
|
370
|
+
desc_event = threading.Event()
|
|
371
|
+
|
|
372
|
+
def handle_description(desc):
|
|
373
|
+
task_info["description"] = desc
|
|
374
|
+
desc_event.set()
|
|
375
|
+
|
|
376
|
+
app.show_multiline_input(
|
|
377
|
+
"Enter task description (Ctrl+S to submit):", "", handle_description
|
|
378
|
+
)
|
|
379
|
+
desc_event.wait()
|
|
380
|
+
|
|
381
|
+
if not task_info["description"]:
|
|
382
|
+
app.show_message("Task description required", "error")
|
|
383
|
+
app._workflow_result = "cancelled"
|
|
384
|
+
result_code["value"] = 1
|
|
385
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
# Step 3: Generate task name if not provided
|
|
389
|
+
if not task_info["name"]:
|
|
390
|
+
app.set_status("setup", "generating task name")
|
|
391
|
+
app.show_message("Generating task name...", "info")
|
|
392
|
+
|
|
393
|
+
# Use prefix for GitHub issues
|
|
394
|
+
prefix = f"issue-{task_info['github_issue']}" if task_info["github_issue"] else None
|
|
395
|
+
task_info["name"] = generate_unique_task_name(task_info["description"], prefix)
|
|
396
|
+
else:
|
|
397
|
+
# Validate provided name for safety (prevent shell injection)
|
|
398
|
+
valid, error_msg = is_valid_task_name(task_info["name"])
|
|
399
|
+
if not valid:
|
|
400
|
+
app.show_message(f"Invalid task name: {error_msg}", "error")
|
|
401
|
+
app._workflow_result = "cancelled"
|
|
402
|
+
result_code["value"] = 1
|
|
403
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# Check if name already exists
|
|
407
|
+
if task_name_exists(task_info["name"]):
|
|
408
|
+
app.show_message(f"Task '{task_info['name']}' already exists", "error")
|
|
409
|
+
app._workflow_result = "cancelled"
|
|
410
|
+
result_code["value"] = 1
|
|
411
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
app.show_message(f"Task name: {task_info['name']}", "success")
|
|
415
|
+
debug_log("Task name generated", name=task_info["name"])
|
|
416
|
+
|
|
417
|
+
# Step 4: Create the task (must happen BEFORE screenshot download
|
|
418
|
+
# because download_issue_screenshots creates the task directory)
|
|
419
|
+
app.set_status("setup", "creating task")
|
|
420
|
+
debug_log("Creating task", name=task_info["name"], type=str(task_info["type"]))
|
|
421
|
+
success, message = create_task(
|
|
422
|
+
task_info["name"],
|
|
423
|
+
task_info["description"],
|
|
424
|
+
task_info["type"],
|
|
425
|
+
github_issue=task_info["github_issue"],
|
|
426
|
+
github_repo=task_info["github_repo"],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if success:
|
|
430
|
+
app.show_message(message, "success")
|
|
431
|
+
app._workflow_result = "task_created"
|
|
432
|
+
debug_log("Task created successfully", name=task_info["name"])
|
|
433
|
+
|
|
434
|
+
# Step 4.5: Download screenshots if from GitHub issue
|
|
435
|
+
# (must happen AFTER task creation since it writes to task directory)
|
|
436
|
+
if task_info.get("_issue_body"):
|
|
437
|
+
app.set_status("setup", "downloading screenshots")
|
|
438
|
+
issue_body = task_info["_issue_body"]
|
|
439
|
+
debug_log(
|
|
440
|
+
"Starting screenshot download",
|
|
441
|
+
body_length=len(issue_body),
|
|
442
|
+
body_preview=issue_body[:200] if issue_body else "empty",
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
from galangal.github.issues import download_issue_screenshots
|
|
446
|
+
|
|
447
|
+
task_dir = get_task_dir(task_info["name"])
|
|
448
|
+
screenshot_paths = download_issue_screenshots(
|
|
449
|
+
task_info["_issue_body"],
|
|
450
|
+
task_dir,
|
|
451
|
+
)
|
|
452
|
+
if screenshot_paths:
|
|
453
|
+
task_info["screenshots"] = screenshot_paths
|
|
454
|
+
app.show_message(
|
|
455
|
+
f"Downloaded {len(screenshot_paths)} screenshot(s)", "success"
|
|
456
|
+
)
|
|
457
|
+
debug_log("Screenshots downloaded", count=len(screenshot_paths))
|
|
458
|
+
|
|
459
|
+
# Update state with screenshot paths
|
|
460
|
+
state = load_state(task_info["name"])
|
|
461
|
+
if state:
|
|
462
|
+
state.screenshots = screenshot_paths
|
|
463
|
+
save_state(state)
|
|
464
|
+
except Exception as e:
|
|
465
|
+
debug_exception("Screenshot download failed", e)
|
|
466
|
+
app.show_message(f"Screenshot download failed: {e}", "warning")
|
|
467
|
+
# Non-critical - continue without screenshots
|
|
468
|
+
|
|
469
|
+
# Mark issue as in-progress if from GitHub
|
|
470
|
+
if task_info["github_issue"]:
|
|
471
|
+
try:
|
|
472
|
+
from galangal.github.issues import mark_issue_in_progress
|
|
473
|
+
|
|
474
|
+
mark_issue_in_progress(task_info["github_issue"])
|
|
475
|
+
app.show_message("Marked issue as in-progress", "info")
|
|
476
|
+
except Exception as e:
|
|
477
|
+
debug_exception("Failed to mark issue as in-progress", e)
|
|
478
|
+
# Non-critical - continue anyway
|
|
479
|
+
else:
|
|
480
|
+
app.show_message(f"Failed: {message}", "error")
|
|
481
|
+
app._workflow_result = "error"
|
|
482
|
+
result_code["value"] = 1
|
|
483
|
+
|
|
484
|
+
except Exception as e:
|
|
485
|
+
debug_exception("Task creation failed", e)
|
|
486
|
+
app.show_message(f"Error: {e}", "error")
|
|
487
|
+
app._workflow_result = "error"
|
|
488
|
+
result_code["value"] = 1
|
|
489
|
+
finally:
|
|
490
|
+
app.call_from_thread(app.set_timer, 0.5, app.exit)
|
|
491
|
+
|
|
492
|
+
# Start creation in background thread
|
|
493
|
+
thread = threading.Thread(target=task_creation_thread, daemon=True)
|
|
494
|
+
app.call_later(thread.start)
|
|
495
|
+
app.run()
|
|
496
|
+
|
|
497
|
+
# Log the TUI result for debugging
|
|
498
|
+
debug_log(
|
|
499
|
+
"TUI app exited",
|
|
500
|
+
result=getattr(app, "_workflow_result", "unknown"),
|
|
501
|
+
task_name=task_info.get("name", "none"),
|
|
502
|
+
result_code=result_code["value"],
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# If task was created, start the workflow
|
|
506
|
+
if app._workflow_result == "task_created" and task_info["name"]:
|
|
507
|
+
debug_log("Task created, loading state", task=task_info["name"])
|
|
508
|
+
state = load_state(task_info["name"])
|
|
509
|
+
if state:
|
|
510
|
+
# Pass skip_discovery flag via state attribute
|
|
511
|
+
if getattr(args, "skip_discovery", False):
|
|
512
|
+
state._skip_discovery = True
|
|
513
|
+
try:
|
|
514
|
+
debug_log("Starting workflow", task=task_info["name"])
|
|
515
|
+
run_workflow(state)
|
|
516
|
+
except Exception as e:
|
|
517
|
+
debug_exception("Workflow failed to start", e)
|
|
518
|
+
from galangal.ui.console import print_error
|
|
519
|
+
|
|
520
|
+
print_error(f"Workflow failed: {e}")
|
|
521
|
+
return 1
|
|
522
|
+
else:
|
|
523
|
+
debug_log("Failed to load state", task=task_info["name"])
|
|
524
|
+
else:
|
|
525
|
+
debug_log(
|
|
526
|
+
"Not starting workflow",
|
|
527
|
+
reason=f"result={getattr(app, '_workflow_result', 'unknown')}, name={task_info.get('name', 'none')}",
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
return result_code["value"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal status - Show active task status.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from galangal.core.artifacts import artifact_exists
|
|
8
|
+
from galangal.core.tasks import get_active_task
|
|
9
|
+
from galangal.ui.console import display_status, print_error, print_info
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
13
|
+
"""Show status of active task."""
|
|
14
|
+
from galangal.config.loader import require_initialized
|
|
15
|
+
from galangal.core.state import get_all_artifact_names, load_state
|
|
16
|
+
|
|
17
|
+
if not require_initialized():
|
|
18
|
+
return 1
|
|
19
|
+
|
|
20
|
+
active = get_active_task()
|
|
21
|
+
if not active:
|
|
22
|
+
print_info("No active task. Use 'list' to see tasks, 'switch' to select one.")
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
state = load_state(active)
|
|
26
|
+
if state is None:
|
|
27
|
+
print_error(f"Could not load state for '{active}'.")
|
|
28
|
+
return 1
|
|
29
|
+
|
|
30
|
+
# Collect artifact status - derived from STAGE_METADATA
|
|
31
|
+
artifacts = [(name, artifact_exists(name, active)) for name in get_all_artifact_names()]
|
|
32
|
+
|
|
33
|
+
display_status(
|
|
34
|
+
task_name=active,
|
|
35
|
+
stage=state.stage,
|
|
36
|
+
task_type=state.task_type,
|
|
37
|
+
attempt=state.attempt,
|
|
38
|
+
awaiting_approval=state.awaiting_approval,
|
|
39
|
+
last_failure=state.last_failure,
|
|
40
|
+
description=state.task_description,
|
|
41
|
+
artifacts=artifacts,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal switch - Switch to a different task.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from galangal.core.state import get_task_dir, load_state
|
|
8
|
+
from galangal.core.tasks import set_active_task
|
|
9
|
+
from galangal.ui.console import console, print_error, print_success
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_switch(args: argparse.Namespace) -> int:
|
|
13
|
+
"""Switch to a different task."""
|
|
14
|
+
task_name = args.task_name
|
|
15
|
+
task_dir = get_task_dir(task_name)
|
|
16
|
+
|
|
17
|
+
if not task_dir.exists():
|
|
18
|
+
print_error(f"Task '{task_name}' not found.")
|
|
19
|
+
return 1
|
|
20
|
+
|
|
21
|
+
set_active_task(task_name)
|
|
22
|
+
state = load_state(task_name)
|
|
23
|
+
if state:
|
|
24
|
+
print_success(f"Switched to: {task_name}")
|
|
25
|
+
console.print(f"[dim]Stage:[/dim] {state.stage.value}")
|
|
26
|
+
console.print(f"[dim]Type:[/dim] {state.task_type.display_name()}")
|
|
27
|
+
console.print(f"[dim]Description:[/dim] {state.task_description[:60]}...")
|
|
28
|
+
return 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Configuration management."""
|
|
2
|
+
|
|
3
|
+
from galangal.config.loader import get_config, get_project_root, load_config
|
|
4
|
+
from galangal.config.schema import GalangalConfig, ProjectConfig, StageConfig
|
|
5
|
+
from galangal.exceptions import ConfigError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ConfigError",
|
|
9
|
+
"load_config",
|
|
10
|
+
"get_project_root",
|
|
11
|
+
"get_config",
|
|
12
|
+
"GalangalConfig",
|
|
13
|
+
"ProjectConfig",
|
|
14
|
+
"StageConfig",
|
|
15
|
+
]
|