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.
Files changed (79) hide show
  1. galangal/__init__.py +36 -0
  2. galangal/__main__.py +6 -0
  3. galangal/ai/__init__.py +167 -0
  4. galangal/ai/base.py +159 -0
  5. galangal/ai/claude.py +352 -0
  6. galangal/ai/codex.py +370 -0
  7. galangal/ai/gemini.py +43 -0
  8. galangal/ai/subprocess.py +254 -0
  9. galangal/cli.py +371 -0
  10. galangal/commands/__init__.py +27 -0
  11. galangal/commands/complete.py +367 -0
  12. galangal/commands/github.py +355 -0
  13. galangal/commands/init.py +177 -0
  14. galangal/commands/init_wizard.py +762 -0
  15. galangal/commands/list.py +20 -0
  16. galangal/commands/pause.py +34 -0
  17. galangal/commands/prompts.py +89 -0
  18. galangal/commands/reset.py +41 -0
  19. galangal/commands/resume.py +30 -0
  20. galangal/commands/skip.py +62 -0
  21. galangal/commands/start.py +530 -0
  22. galangal/commands/status.py +44 -0
  23. galangal/commands/switch.py +28 -0
  24. galangal/config/__init__.py +15 -0
  25. galangal/config/defaults.py +183 -0
  26. galangal/config/loader.py +163 -0
  27. galangal/config/schema.py +330 -0
  28. galangal/core/__init__.py +33 -0
  29. galangal/core/artifacts.py +136 -0
  30. galangal/core/state.py +1097 -0
  31. galangal/core/tasks.py +454 -0
  32. galangal/core/utils.py +116 -0
  33. galangal/core/workflow/__init__.py +68 -0
  34. galangal/core/workflow/core.py +789 -0
  35. galangal/core/workflow/engine.py +781 -0
  36. galangal/core/workflow/pause.py +35 -0
  37. galangal/core/workflow/tui_runner.py +1322 -0
  38. galangal/exceptions.py +36 -0
  39. galangal/github/__init__.py +31 -0
  40. galangal/github/client.py +427 -0
  41. galangal/github/images.py +324 -0
  42. galangal/github/issues.py +298 -0
  43. galangal/logging.py +364 -0
  44. galangal/prompts/__init__.py +5 -0
  45. galangal/prompts/builder.py +527 -0
  46. galangal/prompts/defaults/benchmark.md +34 -0
  47. galangal/prompts/defaults/contract.md +35 -0
  48. galangal/prompts/defaults/design.md +54 -0
  49. galangal/prompts/defaults/dev.md +89 -0
  50. galangal/prompts/defaults/docs.md +104 -0
  51. galangal/prompts/defaults/migration.md +59 -0
  52. galangal/prompts/defaults/pm.md +110 -0
  53. galangal/prompts/defaults/pm_questions.md +53 -0
  54. galangal/prompts/defaults/preflight.md +32 -0
  55. galangal/prompts/defaults/qa.md +65 -0
  56. galangal/prompts/defaults/review.md +90 -0
  57. galangal/prompts/defaults/review_codex.md +99 -0
  58. galangal/prompts/defaults/security.md +84 -0
  59. galangal/prompts/defaults/test.md +91 -0
  60. galangal/results.py +176 -0
  61. galangal/ui/__init__.py +5 -0
  62. galangal/ui/console.py +126 -0
  63. galangal/ui/tui/__init__.py +56 -0
  64. galangal/ui/tui/adapters.py +168 -0
  65. galangal/ui/tui/app.py +902 -0
  66. galangal/ui/tui/entry.py +24 -0
  67. galangal/ui/tui/mixins.py +196 -0
  68. galangal/ui/tui/modals.py +339 -0
  69. galangal/ui/tui/styles/app.tcss +86 -0
  70. galangal/ui/tui/styles/modals.tcss +197 -0
  71. galangal/ui/tui/types.py +107 -0
  72. galangal/ui/tui/widgets.py +263 -0
  73. galangal/validation/__init__.py +5 -0
  74. galangal/validation/runner.py +1072 -0
  75. galangal_orchestrate-0.13.0.dist-info/METADATA +985 -0
  76. galangal_orchestrate-0.13.0.dist-info/RECORD +79 -0
  77. galangal_orchestrate-0.13.0.dist-info/WHEEL +4 -0
  78. galangal_orchestrate-0.13.0.dist-info/entry_points.txt +2 -0
  79. 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