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
galangal/exceptions.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ Custom exceptions for Galangal Orchestrate.
3
+
4
+ All galangal-specific exceptions inherit from GalangalError,
5
+ making it easy to catch all galangal errors in one place.
6
+ """
7
+
8
+
9
+ class GalangalError(Exception):
10
+ """Base exception for all Galangal errors."""
11
+
12
+ pass
13
+
14
+
15
+ class ConfigError(GalangalError):
16
+ """Raised when configuration is invalid or cannot be loaded."""
17
+
18
+ pass
19
+
20
+
21
+ class ValidationError(GalangalError):
22
+ """Raised when stage validation fails."""
23
+
24
+ pass
25
+
26
+
27
+ class WorkflowError(GalangalError):
28
+ """Raised when workflow execution encounters an error."""
29
+
30
+ pass
31
+
32
+
33
+ class TaskError(GalangalError):
34
+ """Raised when task operations fail (create, switch, etc.)."""
35
+
36
+ pass
@@ -0,0 +1,31 @@
1
+ """
2
+ GitHub integration for Galangal Orchestrate.
3
+
4
+ Provides:
5
+ - gh CLI wrapper with auth verification
6
+ - Issue listing and filtering by label
7
+ - PR creation with issue linking
8
+ - Image extraction and download from issue bodies
9
+ """
10
+
11
+ from galangal.github.client import GitHubClient, ensure_github_ready
12
+ from galangal.github.images import download_issue_images, extract_image_urls
13
+ from galangal.github.issues import (
14
+ GitHubIssue,
15
+ IssueTaskData,
16
+ download_issue_screenshots,
17
+ list_issues,
18
+ prepare_issue_for_task,
19
+ )
20
+
21
+ __all__ = [
22
+ "GitHubClient",
23
+ "GitHubIssue",
24
+ "IssueTaskData",
25
+ "download_issue_images",
26
+ "download_issue_screenshots",
27
+ "ensure_github_ready",
28
+ "extract_image_urls",
29
+ "list_issues",
30
+ "prepare_issue_for_task",
31
+ ]
@@ -0,0 +1,427 @@
1
+ """
2
+ GitHub CLI (gh) wrapper with authentication and repository verification.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from galangal.exceptions import GalangalError
13
+
14
+
15
+ class GitHubError(GalangalError):
16
+ """Raised when GitHub operations fail."""
17
+
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class GitHubCheckResult:
23
+ """Result of GitHub setup verification."""
24
+
25
+ gh_installed: bool
26
+ gh_version: str | None
27
+ authenticated: bool
28
+ auth_user: str | None
29
+ auth_scopes: list[str] | None
30
+ repo_accessible: bool
31
+ repo_name: str | None
32
+ errors: list[str]
33
+
34
+ @property
35
+ def is_ready(self) -> bool:
36
+ """Check if GitHub integration is fully ready."""
37
+ return self.gh_installed and self.authenticated and self.repo_accessible
38
+
39
+
40
+ class GitHubClient:
41
+ """
42
+ Wrapper around the GitHub CLI (gh) for repository operations.
43
+
44
+ Uses the gh CLI for all GitHub operations, piggybacking on the user's
45
+ existing authentication rather than managing tokens directly.
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self._gh_path: str | None = None
50
+ self._repo_name: str | None = None
51
+
52
+ @property
53
+ def gh_path(self) -> str | None:
54
+ """Get path to gh executable, cached."""
55
+ if self._gh_path is None:
56
+ self._gh_path = shutil.which("gh")
57
+ return self._gh_path
58
+
59
+ def _run_gh(
60
+ self,
61
+ args: list[str],
62
+ timeout: int = 30,
63
+ check: bool = True,
64
+ ) -> tuple[int, str, str]:
65
+ """
66
+ Run a gh CLI command.
67
+
68
+ Args:
69
+ args: Arguments to pass to gh (e.g., ["issue", "list"])
70
+ timeout: Command timeout in seconds
71
+ check: If True, raise GitHubError on non-zero exit
72
+
73
+ Returns:
74
+ Tuple of (exit_code, stdout, stderr)
75
+
76
+ Raises:
77
+ GitHubError: If gh is not installed or command fails (when check=True)
78
+ """
79
+ if not self.gh_path:
80
+ raise GitHubError("GitHub CLI (gh) is not installed")
81
+
82
+ # Disable gh prompts to prevent hanging (since stdin is captured)
83
+ env = os.environ.copy()
84
+ env["GH_PROMPT_DISABLED"] = "1"
85
+
86
+ try:
87
+ result = subprocess.run(
88
+ [self.gh_path] + args,
89
+ capture_output=True,
90
+ text=True,
91
+ timeout=timeout,
92
+ env=env,
93
+ )
94
+ if check and result.returncode != 0:
95
+ raise GitHubError(f"gh command failed: {result.stderr or result.stdout}")
96
+ return result.returncode, result.stdout, result.stderr
97
+ except subprocess.TimeoutExpired:
98
+ raise GitHubError(f"gh command timed out after {timeout}s")
99
+ except FileNotFoundError:
100
+ raise GitHubError("GitHub CLI (gh) is not installed")
101
+
102
+ def check_installation(self) -> tuple[bool, str | None]:
103
+ """
104
+ Check if gh CLI is installed.
105
+
106
+ Returns:
107
+ Tuple of (is_installed, version_string)
108
+ """
109
+ if not self.gh_path:
110
+ return False, None
111
+
112
+ try:
113
+ code, out, _ = self._run_gh(["--version"], check=False)
114
+ if code == 0:
115
+ # Parse version from "gh version X.Y.Z (YYYY-MM-DD)"
116
+ version = out.strip().split("\n")[0]
117
+ return True, version
118
+ except GitHubError:
119
+ pass
120
+
121
+ return False, None
122
+
123
+ def check_auth(self) -> tuple[bool, str | None, list[str] | None]:
124
+ """
125
+ Check if user is authenticated with gh.
126
+
127
+ Returns:
128
+ Tuple of (is_authenticated, username, scopes)
129
+ """
130
+ try:
131
+ code, out, _ = self._run_gh(
132
+ ["auth", "status", "--show-token"],
133
+ check=False,
134
+ )
135
+
136
+ if code != 0:
137
+ return False, None, None
138
+
139
+ # Parse auth status output
140
+ username = None
141
+ scopes = []
142
+
143
+ for line in out.split("\n"):
144
+ line = line.strip()
145
+ if "Logged in to" in line and "as" in line:
146
+ # "Logged in to github.com as username"
147
+ parts = line.split(" as ")
148
+ if len(parts) >= 2:
149
+ username = parts[-1].strip().rstrip(")")
150
+ elif "Token scopes:" in line:
151
+ # "Token scopes: 'repo', 'read:org'"
152
+ scope_part = line.split(":", 1)[-1].strip()
153
+ scopes = [s.strip().strip("'\"") for s in scope_part.split(",")]
154
+
155
+ return True, username, scopes
156
+
157
+ except GitHubError:
158
+ return False, None, None
159
+
160
+ def check_repo_access(self) -> tuple[bool, str | None]:
161
+ """
162
+ Check if current directory is a GitHub repository and we have access.
163
+
164
+ Returns:
165
+ Tuple of (has_access, repo_name)
166
+ """
167
+ try:
168
+ # Get repo name from gh
169
+ code, out, _ = self._run_gh(
170
+ ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
171
+ check=False,
172
+ )
173
+
174
+ if code == 0 and out.strip():
175
+ repo_name = out.strip()
176
+ self._repo_name = repo_name
177
+ return True, repo_name
178
+
179
+ return False, None
180
+
181
+ except GitHubError:
182
+ return False, None
183
+
184
+ def get_repo_name(self) -> str | None:
185
+ """Get the current repository name (owner/repo format)."""
186
+ if self._repo_name is None:
187
+ _, self._repo_name = self.check_repo_access()
188
+ return self._repo_name
189
+
190
+ def check_setup(self) -> GitHubCheckResult:
191
+ """
192
+ Perform a comprehensive check of GitHub setup.
193
+
194
+ Returns:
195
+ GitHubCheckResult with all check details
196
+ """
197
+ errors = []
198
+
199
+ # Check installation
200
+ gh_installed, gh_version = self.check_installation()
201
+ if not gh_installed:
202
+ errors.append("GitHub CLI (gh) is not installed. Install from: https://cli.github.com")
203
+
204
+ # Check authentication
205
+ authenticated = False
206
+ auth_user = None
207
+ auth_scopes = None
208
+ if gh_installed:
209
+ authenticated, auth_user, auth_scopes = self.check_auth()
210
+ if not authenticated:
211
+ errors.append("Not authenticated. Run: gh auth login")
212
+
213
+ # Check repository access
214
+ repo_accessible = False
215
+ repo_name = None
216
+ if authenticated:
217
+ repo_accessible, repo_name = self.check_repo_access()
218
+ if not repo_accessible:
219
+ errors.append(
220
+ "Cannot access repository. Ensure you're in a git repo "
221
+ "with a GitHub remote and have push access."
222
+ )
223
+
224
+ return GitHubCheckResult(
225
+ gh_installed=gh_installed,
226
+ gh_version=gh_version,
227
+ authenticated=authenticated,
228
+ auth_user=auth_user,
229
+ auth_scopes=auth_scopes,
230
+ repo_accessible=repo_accessible,
231
+ repo_name=repo_name,
232
+ errors=errors,
233
+ )
234
+
235
+ def run_json_command(
236
+ self, args: list[str], timeout: int = 30
237
+ ) -> dict[str, Any] | list[Any] | None:
238
+ """
239
+ Run a gh command that returns JSON.
240
+
241
+ Args:
242
+ args: Arguments to pass to gh
243
+ timeout: Command timeout in seconds
244
+
245
+ Returns:
246
+ Parsed JSON response, or None on error
247
+ """
248
+ _, out, _ = self._run_gh(args, timeout=timeout)
249
+ if out.strip():
250
+ result: dict[str, Any] | list[Any] = json.loads(out)
251
+ return result
252
+ return None
253
+
254
+ def add_issue_comment(self, issue_number: int, body: str) -> bool:
255
+ """
256
+ Add a comment to an issue.
257
+
258
+ Args:
259
+ issue_number: Issue number
260
+ body: Comment body
261
+
262
+ Returns:
263
+ True if successful
264
+ """
265
+ try:
266
+ self._run_gh(["issue", "comment", str(issue_number), "--body", body])
267
+ return True
268
+ except GitHubError:
269
+ return False
270
+
271
+ def add_issue_label(self, issue_number: int, label: str) -> bool:
272
+ """
273
+ Add a label to an issue.
274
+
275
+ Args:
276
+ issue_number: Issue number
277
+ label: Label name
278
+
279
+ Returns:
280
+ True if successful
281
+ """
282
+ try:
283
+ self._run_gh(["issue", "edit", str(issue_number), "--add-label", label])
284
+ return True
285
+ except GitHubError:
286
+ return False
287
+
288
+ def remove_issue_label(self, issue_number: int, label: str) -> bool:
289
+ """
290
+ Remove a label from an issue.
291
+
292
+ Args:
293
+ issue_number: Issue number
294
+ label: Label name
295
+
296
+ Returns:
297
+ True if successful
298
+ """
299
+ try:
300
+ self._run_gh(["issue", "edit", str(issue_number), "--remove-label", label])
301
+ return True
302
+ except GitHubError:
303
+ return False
304
+
305
+ def get_issue_state(self, issue_number: int) -> str | None:
306
+ """
307
+ Get the current state of an issue.
308
+
309
+ Args:
310
+ issue_number: Issue number
311
+
312
+ Returns:
313
+ "open", "closed", or None if not found
314
+ """
315
+ try:
316
+ data = self.run_json_command(["issue", "view", str(issue_number), "--json", "state"])
317
+ if isinstance(data, dict) and "state" in data:
318
+ state = data["state"]
319
+ return str(state).lower() if state else None
320
+ except GitHubError:
321
+ pass
322
+ return None
323
+
324
+ def list_labels(self) -> list[dict[str, Any]] | None:
325
+ """
326
+ List all labels in the repository.
327
+
328
+ Returns:
329
+ List of label dicts with 'name', 'color', 'description' keys,
330
+ or None on error
331
+ """
332
+ try:
333
+ result = self.run_json_command(["label", "list", "--json", "name,color,description"])
334
+ if isinstance(result, list):
335
+ return result
336
+ return None
337
+ except GitHubError:
338
+ return None
339
+
340
+ def label_exists(self, name: str) -> bool:
341
+ """
342
+ Check if a label exists in the repository.
343
+
344
+ Args:
345
+ name: Label name to check
346
+
347
+ Returns:
348
+ True if label exists
349
+ """
350
+ labels = self.list_labels()
351
+ if labels:
352
+ return any(label["name"].lower() == name.lower() for label in labels)
353
+ return False
354
+
355
+ def create_label(
356
+ self,
357
+ name: str,
358
+ color: str = "CCCCCC",
359
+ description: str = "",
360
+ ) -> bool:
361
+ """
362
+ Create a new label in the repository.
363
+
364
+ Args:
365
+ name: Label name
366
+ color: Hex color without # (e.g., "7C3AED")
367
+ description: Label description
368
+
369
+ Returns:
370
+ True if successful
371
+ """
372
+ try:
373
+ args = ["label", "create", name, "--color", color]
374
+ if description:
375
+ args.extend(["--description", description])
376
+ self._run_gh(args)
377
+ return True
378
+ except GitHubError:
379
+ return False
380
+
381
+ def create_label_if_missing(
382
+ self,
383
+ name: str,
384
+ color: str = "CCCCCC",
385
+ description: str = "",
386
+ ) -> tuple[bool, bool]:
387
+ """
388
+ Create a label if it doesn't already exist.
389
+
390
+ Args:
391
+ name: Label name
392
+ color: Hex color without # (e.g., "7C3AED")
393
+ description: Label description
394
+
395
+ Returns:
396
+ Tuple of (success, was_created). success is True if label exists
397
+ (whether created or already existed). was_created is True only
398
+ if the label was newly created.
399
+ """
400
+ if self.label_exists(name):
401
+ return True, False
402
+
403
+ success = self.create_label(name, color, description)
404
+ return success, success
405
+
406
+
407
+ def ensure_github_ready() -> GitHubCheckResult | None:
408
+ """
409
+ Check if GitHub integration is ready for use.
410
+
411
+ This is a convenience function that creates a GitHubClient, performs
412
+ setup verification, and returns the result if ready, or None if not.
413
+
414
+ Returns:
415
+ GitHubCheckResult if GitHub is ready (gh installed, authenticated,
416
+ and repo accessible), or None if any check fails.
417
+
418
+ Example:
419
+ check = ensure_github_ready()
420
+ if not check:
421
+ print("GitHub not ready")
422
+ return
423
+ repo_name = check.repo_name
424
+ """
425
+ client = GitHubClient()
426
+ check = client.check_setup()
427
+ return check if check.is_ready else None