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,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"!\[([^\]]*)\]\(([^)]+)\)", # ![alt text](url)
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 (![alt](url)) 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: ![alt](url)
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)