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/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
|