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,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal complete - Complete a task, commit, and create PR.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
|
|
13
|
+
from galangal.ai import get_backend_with_fallback
|
|
14
|
+
from galangal.config.loader import get_config, get_done_dir, get_project_root
|
|
15
|
+
from galangal.core.artifacts import read_artifact, run_command
|
|
16
|
+
from galangal.core.state import Stage, get_task_dir
|
|
17
|
+
from galangal.core.tasks import clear_active_task
|
|
18
|
+
from galangal.ui.console import console, print_error, print_success, print_warning
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_pr_title(task_name: str, description: str, task_type: str) -> str:
|
|
22
|
+
"""Generate a concise PR title using AI."""
|
|
23
|
+
config = get_config()
|
|
24
|
+
backend = get_backend_with_fallback(config.ai.default, config=config)
|
|
25
|
+
|
|
26
|
+
prompt = f"""Generate a concise pull request title for this task.
|
|
27
|
+
|
|
28
|
+
Task: {task_name}
|
|
29
|
+
Type: {task_type}
|
|
30
|
+
Description: {description[:500]}
|
|
31
|
+
|
|
32
|
+
Requirements:
|
|
33
|
+
1. Max 72 characters
|
|
34
|
+
2. Start with type prefix based on task type:
|
|
35
|
+
- Feature → "feat: ..."
|
|
36
|
+
- Bug Fix → "fix: ..."
|
|
37
|
+
- Refactor → "refactor: ..."
|
|
38
|
+
- Chore → "chore: ..."
|
|
39
|
+
- Docs → "docs: ..."
|
|
40
|
+
- Hotfix → "fix: ..."
|
|
41
|
+
3. Be specific about what changed
|
|
42
|
+
4. Use imperative mood ("Add feature" not "Added feature")
|
|
43
|
+
5. No period at end
|
|
44
|
+
|
|
45
|
+
Output ONLY the title, nothing else."""
|
|
46
|
+
|
|
47
|
+
title = backend.generate_text(prompt, timeout=30)
|
|
48
|
+
if title:
|
|
49
|
+
title = title.split("\n")[0].strip()
|
|
50
|
+
return title[:72] if len(title) > 72 else title
|
|
51
|
+
|
|
52
|
+
# Fallback
|
|
53
|
+
return description[:72] if len(description) > 72 else description
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_commit_summary(
|
|
57
|
+
task_name: str,
|
|
58
|
+
description: str,
|
|
59
|
+
spec: str | None = None,
|
|
60
|
+
plan: str | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Generate a commit message summary using AI.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
task_name: Name of the task
|
|
66
|
+
description: Task description
|
|
67
|
+
spec: Pre-read SPEC.md content (optional, reads from disk if not provided)
|
|
68
|
+
plan: Pre-read PLAN.md content (optional, reads from disk if not provided)
|
|
69
|
+
"""
|
|
70
|
+
config = get_config()
|
|
71
|
+
backend = get_backend_with_fallback(config.ai.default, config=config)
|
|
72
|
+
base_branch = config.pr.base_branch
|
|
73
|
+
|
|
74
|
+
# Use provided artifacts or read from disk
|
|
75
|
+
if spec is None:
|
|
76
|
+
spec = read_artifact("SPEC.md", task_name) or ""
|
|
77
|
+
if plan is None:
|
|
78
|
+
plan = read_artifact("PLAN.md", task_name) or ""
|
|
79
|
+
|
|
80
|
+
code, diff_stat, _ = run_command(["git", "diff", "--stat", f"{base_branch}...HEAD"])
|
|
81
|
+
code, changed_files, _ = run_command(["git", "diff", "--name-only", f"{base_branch}...HEAD"])
|
|
82
|
+
|
|
83
|
+
prompt = f"""Generate a concise git commit message for this task. Follow conventional commit format.
|
|
84
|
+
|
|
85
|
+
Task: {task_name}
|
|
86
|
+
Description: {description}
|
|
87
|
+
|
|
88
|
+
Specification summary:
|
|
89
|
+
{spec[:1000] if spec else "(none)"}
|
|
90
|
+
|
|
91
|
+
Implementation plan summary:
|
|
92
|
+
{plan[:800] if plan else "(none)"}
|
|
93
|
+
|
|
94
|
+
Files changed:
|
|
95
|
+
{changed_files[:1000] if changed_files else "(none)"}
|
|
96
|
+
|
|
97
|
+
Requirements:
|
|
98
|
+
1. First line: type(scope): brief description (max 72 chars)
|
|
99
|
+
- Types: feat, fix, refactor, chore, docs, test, style, perf
|
|
100
|
+
2. Blank line
|
|
101
|
+
3. Body: 2-4 bullet points summarizing key changes
|
|
102
|
+
4. Do NOT include any co-authored-by or generated-by lines
|
|
103
|
+
|
|
104
|
+
Output ONLY the commit message, nothing else."""
|
|
105
|
+
|
|
106
|
+
summary = backend.generate_text(prompt, timeout=60)
|
|
107
|
+
if summary:
|
|
108
|
+
return summary.strip()
|
|
109
|
+
|
|
110
|
+
return f"{description[:72]}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def create_pull_request(
|
|
114
|
+
task_name: str,
|
|
115
|
+
description: str,
|
|
116
|
+
task_type: str,
|
|
117
|
+
github_issue: int | None = None,
|
|
118
|
+
) -> tuple[bool, str]:
|
|
119
|
+
"""Create a pull request for the task branch.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
task_name: Name of the task
|
|
123
|
+
description: Task description
|
|
124
|
+
task_type: Type of task (Feature, Bug Fix, etc.)
|
|
125
|
+
github_issue: Optional GitHub issue number to link
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Tuple of (success, pr_url_or_error)
|
|
129
|
+
"""
|
|
130
|
+
config = get_config()
|
|
131
|
+
branch_name = config.branch_pattern.format(task_name=task_name)
|
|
132
|
+
base_branch = config.pr.base_branch
|
|
133
|
+
|
|
134
|
+
code, current_branch, _ = run_command(["git", "branch", "--show-current"])
|
|
135
|
+
current_branch = current_branch.strip()
|
|
136
|
+
|
|
137
|
+
if current_branch != branch_name:
|
|
138
|
+
code, _, err = run_command(["git", "checkout", branch_name])
|
|
139
|
+
if code != 0:
|
|
140
|
+
return False, f"Could not switch to branch {branch_name}: {err}"
|
|
141
|
+
|
|
142
|
+
code, out, err = run_command(["git", "push", "-u", "origin", branch_name])
|
|
143
|
+
if code != 0:
|
|
144
|
+
if "Everything up-to-date" not in out and "Everything up-to-date" not in err:
|
|
145
|
+
return False, f"Failed to push branch: {err or out}"
|
|
146
|
+
|
|
147
|
+
spec_content = read_artifact("SPEC.md", task_name) or description
|
|
148
|
+
|
|
149
|
+
console.print("[dim]Generating PR title...[/dim]")
|
|
150
|
+
pr_title = generate_pr_title(task_name, description, task_type)
|
|
151
|
+
|
|
152
|
+
# Prefix PR title with issue reference if linked
|
|
153
|
+
if github_issue:
|
|
154
|
+
pr_title = f"Issue #{github_issue}: {pr_title}"
|
|
155
|
+
|
|
156
|
+
# Build PR body
|
|
157
|
+
pr_body = f"""## Summary
|
|
158
|
+
{spec_content[:1500] if len(spec_content) > 1500 else spec_content}
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
# Add issue closing reference if linked
|
|
162
|
+
if github_issue:
|
|
163
|
+
pr_body += f"Closes #{github_issue}\n\n"
|
|
164
|
+
|
|
165
|
+
pr_body += "---\n"
|
|
166
|
+
|
|
167
|
+
# Add codex review if configured
|
|
168
|
+
if config.pr.codex_review:
|
|
169
|
+
pr_body += "@codex review\n"
|
|
170
|
+
|
|
171
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
172
|
+
f.write(pr_body)
|
|
173
|
+
body_file = f.name
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
code, out, err = run_command(
|
|
177
|
+
[
|
|
178
|
+
"gh",
|
|
179
|
+
"pr",
|
|
180
|
+
"create",
|
|
181
|
+
"--title",
|
|
182
|
+
pr_title,
|
|
183
|
+
"--body-file",
|
|
184
|
+
body_file,
|
|
185
|
+
"--base",
|
|
186
|
+
base_branch,
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
finally:
|
|
190
|
+
Path(body_file).unlink(missing_ok=True)
|
|
191
|
+
|
|
192
|
+
if code != 0:
|
|
193
|
+
combined_output = (out + err).lower()
|
|
194
|
+
if "already exists" in combined_output:
|
|
195
|
+
return True, "PR already exists"
|
|
196
|
+
if "pull request create failed" in combined_output:
|
|
197
|
+
code2, pr_url, _ = run_command(["gh", "pr", "view", "--json", "url", "-q", ".url"])
|
|
198
|
+
if code2 == 0 and pr_url.strip():
|
|
199
|
+
return True, pr_url.strip()
|
|
200
|
+
return False, f"Failed to create PR: {err or out}"
|
|
201
|
+
|
|
202
|
+
pr_url = out.strip()
|
|
203
|
+
|
|
204
|
+
# Comment on issue with PR link if linked
|
|
205
|
+
if github_issue and pr_url:
|
|
206
|
+
try:
|
|
207
|
+
from galangal.github.issues import mark_issue_pr_created
|
|
208
|
+
|
|
209
|
+
mark_issue_pr_created(github_issue, pr_url)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass # Non-critical
|
|
212
|
+
|
|
213
|
+
return True, pr_url
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def commit_changes(
|
|
217
|
+
task_name: str,
|
|
218
|
+
description: str,
|
|
219
|
+
spec: str | None = None,
|
|
220
|
+
plan: str | None = None,
|
|
221
|
+
) -> tuple[bool, str]:
|
|
222
|
+
"""Commit all changes for a task.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
task_name: Name of the task
|
|
226
|
+
description: Task description
|
|
227
|
+
spec: Pre-read SPEC.md content (optional)
|
|
228
|
+
plan: Pre-read PLAN.md content (optional)
|
|
229
|
+
"""
|
|
230
|
+
code, status_out, _ = run_command(["git", "status", "--porcelain"])
|
|
231
|
+
if code != 0:
|
|
232
|
+
return False, "Failed to check git status"
|
|
233
|
+
|
|
234
|
+
if not status_out.strip():
|
|
235
|
+
return True, "No changes to commit"
|
|
236
|
+
|
|
237
|
+
changes = [line for line in status_out.strip().split("\n") if line.strip()]
|
|
238
|
+
change_count = len(changes)
|
|
239
|
+
|
|
240
|
+
console.print(f"[dim]Committing {change_count} changed files...[/dim]")
|
|
241
|
+
|
|
242
|
+
code, _, err = run_command(["git", "add", "-A"])
|
|
243
|
+
if code != 0:
|
|
244
|
+
return False, f"Failed to stage changes: {err}"
|
|
245
|
+
|
|
246
|
+
console.print("[dim]Generating commit summary...[/dim]")
|
|
247
|
+
summary = generate_commit_summary(task_name, description, spec=spec, plan=plan)
|
|
248
|
+
|
|
249
|
+
commit_msg = f"""{summary}
|
|
250
|
+
|
|
251
|
+
Task: {task_name}
|
|
252
|
+
Changes: {change_count} files"""
|
|
253
|
+
|
|
254
|
+
code, out, err = run_command(["git", "commit", "-m", commit_msg])
|
|
255
|
+
if code != 0:
|
|
256
|
+
return False, f"Failed to commit: {err or out}"
|
|
257
|
+
|
|
258
|
+
return True, f"Committed {change_count} files"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def finalize_task(
|
|
262
|
+
task_name: str, state, force: bool = False, progress_callback=None
|
|
263
|
+
) -> tuple[bool, str]:
|
|
264
|
+
"""Finalize a completed task: move to done/, commit, create PR.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
task_name: Name of the task to finalize
|
|
268
|
+
state: WorkflowState object
|
|
269
|
+
force: If True, continue even on errors
|
|
270
|
+
progress_callback: Optional callback(message, status) for progress updates.
|
|
271
|
+
status is one of: 'info', 'success', 'warning', 'error'
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Tuple of (success, pr_url_or_error_message)
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def report(message: str, status: str = "info"):
|
|
278
|
+
if progress_callback:
|
|
279
|
+
progress_callback(message, status)
|
|
280
|
+
else:
|
|
281
|
+
if status == "success":
|
|
282
|
+
print_success(message)
|
|
283
|
+
elif status == "warning":
|
|
284
|
+
print_warning(message)
|
|
285
|
+
elif status == "error":
|
|
286
|
+
print_error(message)
|
|
287
|
+
else:
|
|
288
|
+
console.print(f"[dim]{message}[/dim]")
|
|
289
|
+
|
|
290
|
+
config = get_config()
|
|
291
|
+
project_root = get_project_root()
|
|
292
|
+
done_dir = get_done_dir()
|
|
293
|
+
|
|
294
|
+
# Pre-read artifacts before moving task (for commit message generation)
|
|
295
|
+
spec = read_artifact("SPEC.md", task_name) or ""
|
|
296
|
+
plan = read_artifact("PLAN.md", task_name) or ""
|
|
297
|
+
|
|
298
|
+
# 1. Move to done/
|
|
299
|
+
task_dir = get_task_dir(task_name)
|
|
300
|
+
done_dir.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
dest = done_dir / task_name
|
|
302
|
+
|
|
303
|
+
if dest.exists():
|
|
304
|
+
dest = done_dir / f"{task_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
|
305
|
+
|
|
306
|
+
report(f"Moving task to {dest.relative_to(project_root)}/...")
|
|
307
|
+
shutil.move(str(task_dir), str(dest))
|
|
308
|
+
clear_active_task()
|
|
309
|
+
|
|
310
|
+
# 2. Commit changes (pass pre-read artifacts since task dir was moved)
|
|
311
|
+
report("Committing changes...")
|
|
312
|
+
success, msg = commit_changes(task_name, state.task_description, spec=spec, plan=plan)
|
|
313
|
+
if success:
|
|
314
|
+
report(msg, "success")
|
|
315
|
+
else:
|
|
316
|
+
report(msg, "warning")
|
|
317
|
+
if not force and not progress_callback:
|
|
318
|
+
# Only prompt in non-TUI mode
|
|
319
|
+
confirm = Prompt.ask("Continue anyway? [y/N]", default="n").strip().lower()
|
|
320
|
+
if confirm != "y":
|
|
321
|
+
shutil.move(str(dest), str(task_dir))
|
|
322
|
+
from galangal.core.tasks import set_active_task
|
|
323
|
+
|
|
324
|
+
set_active_task(task_name)
|
|
325
|
+
report("Aborted. Task restored to original location.", "warning")
|
|
326
|
+
return False, "Aborted by user"
|
|
327
|
+
|
|
328
|
+
# 3. Create PR
|
|
329
|
+
report("Creating pull request...")
|
|
330
|
+
success, msg = create_pull_request(
|
|
331
|
+
task_name,
|
|
332
|
+
state.task_description,
|
|
333
|
+
state.task_type.display_name(),
|
|
334
|
+
github_issue=state.github_issue,
|
|
335
|
+
)
|
|
336
|
+
pr_url = ""
|
|
337
|
+
if success:
|
|
338
|
+
pr_url = msg
|
|
339
|
+
report(f"PR: {msg}", "success")
|
|
340
|
+
else:
|
|
341
|
+
report(f"Could not create PR: {msg}", "warning")
|
|
342
|
+
report("You may need to create the PR manually.", "info")
|
|
343
|
+
|
|
344
|
+
report(f"Task '{task_name}' completed and moved to {config.tasks_dir}/done/", "success")
|
|
345
|
+
|
|
346
|
+
# 4. Switch back to base branch
|
|
347
|
+
run_command(["git", "checkout", config.pr.base_branch])
|
|
348
|
+
report(f"Switched back to {config.pr.base_branch} branch", "info")
|
|
349
|
+
|
|
350
|
+
return True, pr_url
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cmd_complete(args: argparse.Namespace) -> int:
|
|
354
|
+
"""Move completed task to done/, commit, create PR."""
|
|
355
|
+
from galangal.core.tasks import ensure_active_task_with_state
|
|
356
|
+
|
|
357
|
+
active, state = ensure_active_task_with_state()
|
|
358
|
+
if not active or not state:
|
|
359
|
+
return 1
|
|
360
|
+
|
|
361
|
+
if state.stage != Stage.COMPLETE:
|
|
362
|
+
print_error(f"Task is at stage {state.stage.value}, not COMPLETE.")
|
|
363
|
+
console.print("Run 'resume' to continue the workflow.")
|
|
364
|
+
return 1
|
|
365
|
+
|
|
366
|
+
success, _ = finalize_task(active, state, force=args.force)
|
|
367
|
+
return 0 if success else 1
|