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,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub issue image extraction and download.
|
|
3
|
+
|
|
4
|
+
Parses markdown content for image URLs and downloads them locally
|
|
5
|
+
so Claude Code can view them during workflow execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
# Patterns for finding images in markdown
|
|
16
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
17
|
+
r"!\[([^\]]*)\]\(([^)]+)\)", # 
|
|
18
|
+
re.MULTILINE,
|
|
19
|
+
)
|
|
20
|
+
HTML_IMG_PATTERN = re.compile(
|
|
21
|
+
r'<img[^>]+src=["\']([^"\']+)["\']', # <img src="url">
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# GitHub domains that host user-uploaded images
|
|
26
|
+
GITHUB_IMAGE_DOMAINS = {
|
|
27
|
+
"user-images.githubusercontent.com",
|
|
28
|
+
"github.com",
|
|
29
|
+
"raw.githubusercontent.com",
|
|
30
|
+
"camo.githubusercontent.com",
|
|
31
|
+
"private-user-images.githubusercontent.com",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Supported image extensions
|
|
35
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_image_urls(markdown_text: str) -> list[dict]:
|
|
39
|
+
"""
|
|
40
|
+
Extract image URLs from markdown text.
|
|
41
|
+
|
|
42
|
+
Finds both markdown-style images () and HTML img tags.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
markdown_text: Raw markdown content (e.g., GitHub issue body)
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of dicts with keys: 'url', 'alt_text', 'source' ('markdown' or 'html')
|
|
49
|
+
"""
|
|
50
|
+
images = []
|
|
51
|
+
seen_urls = set()
|
|
52
|
+
|
|
53
|
+
# Find markdown-style images: 
|
|
54
|
+
for match in MARKDOWN_IMAGE_PATTERN.finditer(markdown_text):
|
|
55
|
+
alt_text = match.group(1)
|
|
56
|
+
url = match.group(2).strip()
|
|
57
|
+
|
|
58
|
+
if url and url not in seen_urls and _is_image_url(url):
|
|
59
|
+
images.append(
|
|
60
|
+
{
|
|
61
|
+
"url": url,
|
|
62
|
+
"alt_text": alt_text,
|
|
63
|
+
"source": "markdown",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
seen_urls.add(url)
|
|
67
|
+
|
|
68
|
+
# Find HTML img tags: <img src="url">
|
|
69
|
+
for match in HTML_IMG_PATTERN.finditer(markdown_text):
|
|
70
|
+
url = match.group(1).strip()
|
|
71
|
+
|
|
72
|
+
if url and url not in seen_urls and _is_image_url(url):
|
|
73
|
+
images.append(
|
|
74
|
+
{
|
|
75
|
+
"url": url,
|
|
76
|
+
"alt_text": "",
|
|
77
|
+
"source": "html",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
seen_urls.add(url)
|
|
81
|
+
|
|
82
|
+
return images
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_image_url(url: str) -> bool:
|
|
86
|
+
"""Check if a URL appears to be an image."""
|
|
87
|
+
try:
|
|
88
|
+
parsed = urlparse(url)
|
|
89
|
+
|
|
90
|
+
# Check if it's a GitHub user content URL (these are always images)
|
|
91
|
+
if parsed.netloc in GITHUB_IMAGE_DOMAINS:
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
# Check file extension
|
|
95
|
+
path_lower = parsed.path.lower()
|
|
96
|
+
for ext in IMAGE_EXTENSIONS:
|
|
97
|
+
if path_lower.endswith(ext):
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
# GitHub blob URLs often contain images
|
|
101
|
+
if "blob" in parsed.path and any(ext in path_lower for ext in IMAGE_EXTENSIONS):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
return False
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _generate_filename(url: str, alt_text: str, index: int) -> str:
|
|
110
|
+
"""Generate a filename for a downloaded image."""
|
|
111
|
+
parsed = urlparse(url)
|
|
112
|
+
path = parsed.path
|
|
113
|
+
|
|
114
|
+
# Try to get extension from URL
|
|
115
|
+
ext = ".png" # Default
|
|
116
|
+
for e in IMAGE_EXTENSIONS:
|
|
117
|
+
if path.lower().endswith(e):
|
|
118
|
+
ext = e
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
# Create a descriptive name
|
|
122
|
+
if alt_text:
|
|
123
|
+
# Sanitize alt text for filename
|
|
124
|
+
safe_alt = re.sub(r"[^\w\s-]", "", alt_text)[:30].strip()
|
|
125
|
+
safe_alt = re.sub(r"[\s]+", "_", safe_alt).lower()
|
|
126
|
+
if safe_alt:
|
|
127
|
+
return f"screenshot_{index:02d}_{safe_alt}{ext}"
|
|
128
|
+
|
|
129
|
+
# Use hash of URL for uniqueness
|
|
130
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
|
131
|
+
return f"screenshot_{index:02d}_{url_hash}{ext}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def download_images(
|
|
135
|
+
images: list[dict],
|
|
136
|
+
output_dir: Path,
|
|
137
|
+
use_gh_auth: bool = True,
|
|
138
|
+
) -> list[dict]:
|
|
139
|
+
"""
|
|
140
|
+
Download images to a local directory.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
images: List of image dicts from extract_image_urls()
|
|
144
|
+
output_dir: Directory to save images (e.g., task_dir/screenshots)
|
|
145
|
+
use_gh_auth: If True, use gh CLI for authenticated downloads
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
List of dicts with 'local_path', 'url', 'alt_text', 'success', 'error'
|
|
149
|
+
"""
|
|
150
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
results = []
|
|
152
|
+
|
|
153
|
+
for i, img in enumerate(images, 1):
|
|
154
|
+
url = img["url"]
|
|
155
|
+
alt_text = img.get("alt_text", "")
|
|
156
|
+
filename = _generate_filename(url, alt_text, i)
|
|
157
|
+
local_path = output_dir / filename
|
|
158
|
+
|
|
159
|
+
result = {
|
|
160
|
+
"url": url,
|
|
161
|
+
"alt_text": alt_text,
|
|
162
|
+
"local_path": str(local_path),
|
|
163
|
+
"success": False,
|
|
164
|
+
"error": None,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
parsed = urlparse(url)
|
|
169
|
+
is_github_url = parsed.netloc in GITHUB_IMAGE_DOMAINS
|
|
170
|
+
|
|
171
|
+
# Try gh CLI for GitHub URLs (handles auth)
|
|
172
|
+
if use_gh_auth and is_github_url:
|
|
173
|
+
success = _download_with_gh(url, local_path)
|
|
174
|
+
if success:
|
|
175
|
+
result["success"] = True
|
|
176
|
+
results.append(result)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Fall back to direct download
|
|
180
|
+
_download_direct(url, local_path)
|
|
181
|
+
result["success"] = True
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
result["error"] = str(e)
|
|
185
|
+
|
|
186
|
+
results.append(result)
|
|
187
|
+
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _download_with_gh(url: str, output_path: Path) -> bool:
|
|
192
|
+
"""
|
|
193
|
+
Download a URL using gh CLI (handles GitHub authentication).
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
url: URL to download
|
|
197
|
+
output_path: Path to save the file
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if successful, False otherwise
|
|
201
|
+
"""
|
|
202
|
+
from galangal.core.utils import debug_log
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
parsed = urlparse(url)
|
|
206
|
+
|
|
207
|
+
# For private-user-images or user-attachments, use gh api
|
|
208
|
+
# These URLs require authenticated access for private repos
|
|
209
|
+
if parsed.netloc == "private-user-images.githubusercontent.com" or (
|
|
210
|
+
parsed.netloc == "github.com" and "/user-attachments/" in parsed.path
|
|
211
|
+
):
|
|
212
|
+
debug_log("Downloading with gh api", url=url[:80])
|
|
213
|
+
# Use gh api to fetch with authentication
|
|
214
|
+
result = subprocess.run(
|
|
215
|
+
["gh", "api", "-H", "Accept: application/octet-stream", url],
|
|
216
|
+
capture_output=True,
|
|
217
|
+
timeout=60,
|
|
218
|
+
)
|
|
219
|
+
if result.returncode == 0 and result.stdout:
|
|
220
|
+
output_path.write_bytes(result.stdout)
|
|
221
|
+
debug_log("gh api download successful", path=str(output_path))
|
|
222
|
+
return True
|
|
223
|
+
debug_log(
|
|
224
|
+
"gh api download failed",
|
|
225
|
+
returncode=result.returncode,
|
|
226
|
+
stderr=result.stderr.decode()[:200] if result.stderr else "",
|
|
227
|
+
)
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
# For other GitHub URLs, use curl with gh auth token
|
|
231
|
+
debug_log("Downloading with auth token", url=url[:80])
|
|
232
|
+
result = subprocess.run(
|
|
233
|
+
["gh", "auth", "token"],
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
timeout=10,
|
|
237
|
+
)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
debug_log("Failed to get gh auth token")
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
token = result.stdout.strip()
|
|
243
|
+
if not token:
|
|
244
|
+
debug_log("gh auth token is empty")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
# Download with auth header
|
|
248
|
+
headers = {"Authorization": f"token {token}"}
|
|
249
|
+
request = urllib.request.Request(url, headers=headers)
|
|
250
|
+
|
|
251
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
252
|
+
output_path.write_bytes(response.read())
|
|
253
|
+
debug_log("Auth token download successful", path=str(output_path))
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
debug_log("Download with gh failed", error=str(e))
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _download_direct(url: str, output_path: Path) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Download a URL directly without authentication.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
url: URL to download
|
|
267
|
+
output_path: Path to save the file
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
Exception on download failure
|
|
271
|
+
"""
|
|
272
|
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; galangal-orchestrate/1.0)"}
|
|
273
|
+
request = urllib.request.Request(url, headers=headers)
|
|
274
|
+
|
|
275
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
276
|
+
output_path.write_bytes(response.read())
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def download_issue_images(
|
|
280
|
+
markdown_body: str,
|
|
281
|
+
task_dir: Path,
|
|
282
|
+
screenshots_subdir: str = "screenshots",
|
|
283
|
+
) -> list[str]:
|
|
284
|
+
"""
|
|
285
|
+
Extract and download all images from a GitHub issue body.
|
|
286
|
+
|
|
287
|
+
This is the main entry point for integrating with task creation.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
markdown_body: The raw issue body markdown
|
|
291
|
+
task_dir: Base directory for the task (e.g., galangal-tasks/issue-42-fix-bug)
|
|
292
|
+
screenshots_subdir: Subdirectory name for screenshots (default: "screenshots")
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of local file paths for successfully downloaded images
|
|
296
|
+
"""
|
|
297
|
+
from galangal.core.utils import debug_log
|
|
298
|
+
|
|
299
|
+
images = extract_image_urls(markdown_body)
|
|
300
|
+
debug_log(
|
|
301
|
+
"Extracted image URLs from issue body",
|
|
302
|
+
count=len(images),
|
|
303
|
+
urls=[img["url"][:80] for img in images[:5]], # First 5, truncated
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if not images:
|
|
307
|
+
debug_log("No images found in issue body")
|
|
308
|
+
return []
|
|
309
|
+
|
|
310
|
+
output_dir = task_dir / screenshots_subdir
|
|
311
|
+
results = download_images(images, output_dir)
|
|
312
|
+
|
|
313
|
+
# Log results
|
|
314
|
+
successful = [r for r in results if r["success"]]
|
|
315
|
+
failed = [r for r in results if not r["success"]]
|
|
316
|
+
debug_log(
|
|
317
|
+
"Image download results",
|
|
318
|
+
successful=len(successful),
|
|
319
|
+
failed=len(failed),
|
|
320
|
+
errors=[r.get("error") for r in failed if r.get("error")],
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Return only successful downloads
|
|
324
|
+
return [r["local_path"] for r in results if r["success"]]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub issue listing and parsing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from galangal.github.client import GitHubClient, GitHubError
|
|
8
|
+
|
|
9
|
+
# Default label for galangal-managed issues
|
|
10
|
+
GALANGAL_LABEL = "galangal"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GitHubIssue:
|
|
15
|
+
"""Representation of a GitHub issue."""
|
|
16
|
+
|
|
17
|
+
number: int
|
|
18
|
+
title: str
|
|
19
|
+
body: str
|
|
20
|
+
labels: list[str]
|
|
21
|
+
state: str
|
|
22
|
+
url: str
|
|
23
|
+
author: str
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict) -> "GitHubIssue":
|
|
27
|
+
"""Create from gh JSON output."""
|
|
28
|
+
return cls(
|
|
29
|
+
number=data["number"],
|
|
30
|
+
title=data["title"],
|
|
31
|
+
body=data.get("body") or "",
|
|
32
|
+
labels=[label["name"] for label in data.get("labels", [])],
|
|
33
|
+
state=data.get("state", "open").lower(),
|
|
34
|
+
url=data.get("url", ""),
|
|
35
|
+
author=data.get("author", {}).get("login", "unknown"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_task_name_prefix(self) -> str:
|
|
39
|
+
"""Generate a task name prefix from the issue number."""
|
|
40
|
+
return f"issue-{self.number}"
|
|
41
|
+
|
|
42
|
+
def get_task_type_hint(self) -> str | None:
|
|
43
|
+
"""
|
|
44
|
+
Infer task type from issue labels using config-based mapping.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Suggested task type or None if no match
|
|
48
|
+
"""
|
|
49
|
+
from galangal.config.loader import get_config
|
|
50
|
+
|
|
51
|
+
label_lower = [lbl.lower() for lbl in self.labels]
|
|
52
|
+
|
|
53
|
+
# Get label mapping from config
|
|
54
|
+
try:
|
|
55
|
+
config = get_config()
|
|
56
|
+
mapping = config.github.label_mapping
|
|
57
|
+
except Exception:
|
|
58
|
+
# Fall back to defaults if config not available
|
|
59
|
+
mapping = None
|
|
60
|
+
|
|
61
|
+
if mapping:
|
|
62
|
+
# Check each task type's labels
|
|
63
|
+
for label in label_lower:
|
|
64
|
+
if label in [lbl.lower() for lbl in mapping.bug]:
|
|
65
|
+
return "bug_fix"
|
|
66
|
+
if label in [lbl.lower() for lbl in mapping.feature]:
|
|
67
|
+
return "feature"
|
|
68
|
+
if label in [lbl.lower() for lbl in mapping.docs]:
|
|
69
|
+
return "docs"
|
|
70
|
+
if label in [lbl.lower() for lbl in mapping.refactor]:
|
|
71
|
+
return "refactor"
|
|
72
|
+
if label in [lbl.lower() for lbl in mapping.chore]:
|
|
73
|
+
return "chore"
|
|
74
|
+
if label in [lbl.lower() for lbl in mapping.hotfix]:
|
|
75
|
+
return "hotfix"
|
|
76
|
+
else:
|
|
77
|
+
# Fallback to hardcoded defaults
|
|
78
|
+
if "bug" in label_lower or "bugfix" in label_lower:
|
|
79
|
+
return "bug_fix"
|
|
80
|
+
if "enhancement" in label_lower or "feature" in label_lower:
|
|
81
|
+
return "feature"
|
|
82
|
+
if "documentation" in label_lower or "docs" in label_lower:
|
|
83
|
+
return "docs"
|
|
84
|
+
if "refactor" in label_lower:
|
|
85
|
+
return "refactor"
|
|
86
|
+
if "chore" in label_lower or "maintenance" in label_lower:
|
|
87
|
+
return "chore"
|
|
88
|
+
if "hotfix" in label_lower or "critical" in label_lower:
|
|
89
|
+
return "hotfix"
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def list_issues(
|
|
95
|
+
label: str = GALANGAL_LABEL,
|
|
96
|
+
state: str = "open",
|
|
97
|
+
limit: int = 50,
|
|
98
|
+
) -> list[GitHubIssue]:
|
|
99
|
+
"""
|
|
100
|
+
List issues from the current repository with the given label.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
label: Label to filter by (default: "galangal")
|
|
104
|
+
state: Issue state filter ("open", "closed", "all")
|
|
105
|
+
limit: Maximum number of issues to return
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of GitHubIssue objects
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
GitHubError: If GitHub operations fail
|
|
112
|
+
"""
|
|
113
|
+
client = GitHubClient()
|
|
114
|
+
|
|
115
|
+
data = client.run_json_command(
|
|
116
|
+
[
|
|
117
|
+
"issue",
|
|
118
|
+
"list",
|
|
119
|
+
"--label",
|
|
120
|
+
label,
|
|
121
|
+
"--state",
|
|
122
|
+
state,
|
|
123
|
+
"--limit",
|
|
124
|
+
str(limit),
|
|
125
|
+
"--json",
|
|
126
|
+
"number,title,body,labels,state,url,author",
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not data:
|
|
131
|
+
return []
|
|
132
|
+
|
|
133
|
+
return [GitHubIssue.from_dict(item) for item in data]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_issue(issue_number: int) -> GitHubIssue | None:
|
|
137
|
+
"""
|
|
138
|
+
Get a single issue by number.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
issue_number: The issue number
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
GitHubIssue or None if not found
|
|
145
|
+
"""
|
|
146
|
+
client = GitHubClient()
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
data = client.run_json_command(
|
|
150
|
+
[
|
|
151
|
+
"issue",
|
|
152
|
+
"view",
|
|
153
|
+
str(issue_number),
|
|
154
|
+
"--json",
|
|
155
|
+
"number,title,body,labels,state,url,author",
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if data:
|
|
160
|
+
return GitHubIssue.from_dict(data)
|
|
161
|
+
except GitHubError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_issue_open(issue_number: int) -> bool | None:
|
|
168
|
+
"""
|
|
169
|
+
Check if an issue is still open.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
issue_number: The issue number
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if open, False if closed, None if not found/error
|
|
176
|
+
"""
|
|
177
|
+
client = GitHubClient()
|
|
178
|
+
state = client.get_issue_state(issue_number)
|
|
179
|
+
if state is None:
|
|
180
|
+
return None
|
|
181
|
+
return state == "open"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def mark_issue_in_progress(issue_number: int) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Mark an issue as being worked on by galangal.
|
|
187
|
+
|
|
188
|
+
Adds "in-progress" label and removes "galangal" label.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
issue_number: The issue number
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
True if successful
|
|
195
|
+
"""
|
|
196
|
+
client = GitHubClient()
|
|
197
|
+
success1 = client.add_issue_label(issue_number, "in-progress")
|
|
198
|
+
success2 = client.remove_issue_label(issue_number, GALANGAL_LABEL)
|
|
199
|
+
return success1 and success2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def mark_issue_pr_created(issue_number: int, pr_url: str) -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Mark an issue as having a PR created.
|
|
205
|
+
|
|
206
|
+
Adds a comment with the PR link.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
issue_number: The issue number
|
|
210
|
+
pr_url: URL to the created PR
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True if successful
|
|
214
|
+
"""
|
|
215
|
+
client = GitHubClient()
|
|
216
|
+
comment = f"Pull request created: {pr_url}"
|
|
217
|
+
return client.add_issue_comment(issue_number, comment)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class IssueTaskData:
|
|
222
|
+
"""Data extracted from a GitHub issue for task creation."""
|
|
223
|
+
|
|
224
|
+
issue_number: int
|
|
225
|
+
description: str
|
|
226
|
+
task_type_hint: str | None
|
|
227
|
+
github_repo: str | None
|
|
228
|
+
screenshots: list[str]
|
|
229
|
+
issue_body: str # Raw body for later screenshot download
|
|
230
|
+
task_name: str | None = None # Generated task name (set after creation)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def prepare_issue_for_task(
|
|
234
|
+
issue: GitHubIssue,
|
|
235
|
+
repo_name: str | None = None,
|
|
236
|
+
) -> IssueTaskData:
|
|
237
|
+
"""
|
|
238
|
+
Extract task creation data from a GitHub issue.
|
|
239
|
+
|
|
240
|
+
This consolidates all the issue-to-task conversion logic:
|
|
241
|
+
- Forms description from title + body
|
|
242
|
+
- Infers task type from labels
|
|
243
|
+
- Identifies screenshots for later download
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
issue: The GitHub issue to process
|
|
247
|
+
repo_name: Optional repo name (owner/repo), fetched if not provided
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
IssueTaskData with all info needed for task creation
|
|
251
|
+
|
|
252
|
+
Note:
|
|
253
|
+
Screenshots are NOT downloaded here - the task directory needs to
|
|
254
|
+
exist first. Use download_issue_screenshots() after task creation.
|
|
255
|
+
"""
|
|
256
|
+
# Get repo name if not provided
|
|
257
|
+
if not repo_name:
|
|
258
|
+
client = GitHubClient()
|
|
259
|
+
repo_name = client.get_repo_name()
|
|
260
|
+
|
|
261
|
+
# Form description from title and body
|
|
262
|
+
description = f"{issue.title}\n\n{issue.body}"
|
|
263
|
+
|
|
264
|
+
# Note: screenshots are extracted later via download_issue_screenshots()
|
|
265
|
+
# We just store the body for later processing
|
|
266
|
+
|
|
267
|
+
return IssueTaskData(
|
|
268
|
+
issue_number=issue.number,
|
|
269
|
+
description=description,
|
|
270
|
+
task_type_hint=issue.get_task_type_hint(),
|
|
271
|
+
github_repo=repo_name,
|
|
272
|
+
screenshots=[], # Populated after task dir exists
|
|
273
|
+
issue_body=issue.body,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def download_issue_screenshots(
|
|
278
|
+
issue_body: str,
|
|
279
|
+
task_dir,
|
|
280
|
+
) -> list[str]:
|
|
281
|
+
"""
|
|
282
|
+
Download screenshots from an issue body to the task directory.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
issue_body: The raw issue body markdown
|
|
286
|
+
task_dir: Path to the task directory (will create screenshots/ subdir)
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of local file paths to downloaded screenshots
|
|
290
|
+
"""
|
|
291
|
+
from pathlib import Path
|
|
292
|
+
|
|
293
|
+
from galangal.github.images import download_issue_images
|
|
294
|
+
|
|
295
|
+
task_dir = Path(task_dir)
|
|
296
|
+
task_dir.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
return download_issue_images(issue_body, task_dir)
|