ensemble-claude 0.3.1__py3-none-any.whl → 0.4.1__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.
ensemble/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """Ensemble AI Orchestration - Multi-agent orchestration for Claude Code."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.1"
4
4
  __author__ = "Ensemble Team"
5
5
 
ensemble/cli.py CHANGED
@@ -4,6 +4,7 @@ import click
4
4
 
5
5
  from ensemble import __version__
6
6
  from ensemble.commands.init import init
7
+ from ensemble.commands.issue import issue
7
8
  from ensemble.commands.launch import launch
8
9
 
9
10
 
@@ -19,6 +20,7 @@ def cli() -> None:
19
20
 
20
21
 
21
22
  cli.add_command(init)
23
+ cli.add_command(issue)
22
24
  cli.add_command(launch)
23
25
 
24
26
 
@@ -0,0 +1,150 @@
1
+ """Implementation of the ensemble issue command."""
2
+
3
+ import click
4
+
5
+ from ensemble.issue_provider import Issue, IssueProvider
6
+ from ensemble.providers.github import GitHubProvider
7
+
8
+
9
+ def get_provider(provider_name: str) -> IssueProvider:
10
+ """Get the issue provider by name.
11
+
12
+ Args:
13
+ provider_name: Name of the provider ('github', 'gitlab').
14
+
15
+ Returns:
16
+ IssueProvider instance.
17
+
18
+ Raises:
19
+ click.ClickException: If provider is not available.
20
+ """
21
+ if provider_name == "github":
22
+ provider = GitHubProvider()
23
+ if not provider.is_available():
24
+ raise click.ClickException(
25
+ "GitHub CLI (gh) is not installed. "
26
+ "Install it from: https://cli.github.com/"
27
+ )
28
+ return provider
29
+ elif provider_name == "gitlab":
30
+ raise click.ClickException(
31
+ "GitLab provider is not yet implemented. Coming soon!"
32
+ )
33
+ else:
34
+ raise click.ClickException(f"Unknown provider: {provider_name}")
35
+
36
+
37
+ def run_issue(
38
+ identifier: str | None,
39
+ state: str,
40
+ provider: str,
41
+ ) -> None:
42
+ """Run the issue command implementation.
43
+
44
+ Args:
45
+ identifier: Issue number or URL (None to list issues).
46
+ state: Filter state for listing ('open', 'closed', 'all').
47
+ provider: Provider name ('github', 'gitlab').
48
+ """
49
+ issue_provider = get_provider(provider)
50
+
51
+ if identifier:
52
+ # View specific issue
53
+ _view_issue(issue_provider, identifier)
54
+ else:
55
+ # List issues
56
+ _list_issues(issue_provider, state)
57
+
58
+
59
+ def _view_issue(provider: IssueProvider, identifier: str) -> None:
60
+ """View a specific issue.
61
+
62
+ Args:
63
+ provider: Issue provider instance.
64
+ identifier: Issue number or URL.
65
+ """
66
+ try:
67
+ issue = provider.get_issue(identifier)
68
+ _print_issue_detail(issue)
69
+ except ValueError as e:
70
+ raise click.ClickException(str(e))
71
+ except RuntimeError as e:
72
+ raise click.ClickException(str(e))
73
+
74
+
75
+ def _list_issues(provider: IssueProvider, state: str) -> None:
76
+ """List issues.
77
+
78
+ Args:
79
+ provider: Issue provider instance.
80
+ state: Filter state.
81
+ """
82
+ try:
83
+ issues = provider.list_issues(state=state)
84
+
85
+ if not issues:
86
+ click.echo(f"No {state} issues found.")
87
+ return
88
+
89
+ click.echo(f"\n{click.style(f'{len(issues)} {state} issue(s):', bold=True)}\n")
90
+
91
+ for issue in issues:
92
+ _print_issue_summary(issue)
93
+
94
+ click.echo("")
95
+
96
+ except RuntimeError as e:
97
+ raise click.ClickException(str(e))
98
+
99
+
100
+ def _print_issue_summary(issue: Issue) -> None:
101
+ """Print a one-line summary of an issue.
102
+
103
+ Args:
104
+ issue: Issue to print.
105
+ """
106
+ # Format: #123 [bug, urgent] Issue title
107
+ number = click.style(f"#{issue.number}", fg="cyan", bold=True)
108
+ labels = ""
109
+ if issue.labels:
110
+ label_str = ", ".join(issue.labels[:3]) # Max 3 labels
111
+ if len(issue.labels) > 3:
112
+ label_str += ", ..."
113
+ labels = click.style(f" [{label_str}]", fg="yellow")
114
+
115
+ # Truncate title if too long
116
+ title = issue.title
117
+ if len(title) > 60:
118
+ title = title[:57] + "..."
119
+
120
+ click.echo(f" {number}{labels} {title}")
121
+
122
+
123
+ def _print_issue_detail(issue: Issue) -> None:
124
+ """Print detailed view of an issue.
125
+
126
+ Args:
127
+ issue: Issue to print.
128
+ """
129
+ click.echo("")
130
+ click.echo(click.style(f"Issue #{issue.number}", fg="cyan", bold=True))
131
+ click.echo(click.style("=" * 60, dim=True))
132
+ click.echo("")
133
+ click.echo(f"{click.style('Title:', bold=True)} {issue.title}")
134
+ click.echo(f"{click.style('State:', bold=True)} {issue.state}")
135
+
136
+ if issue.labels:
137
+ labels = ", ".join(issue.labels)
138
+ click.echo(f"{click.style('Labels:', bold=True)} {labels}")
139
+
140
+ click.echo(f"{click.style('URL:', bold=True)} {issue.url}")
141
+ click.echo("")
142
+
143
+ if issue.body:
144
+ click.echo(click.style("Description:", bold=True))
145
+ click.echo(click.style("-" * 40, dim=True))
146
+ click.echo(issue.body)
147
+ else:
148
+ click.echo(click.style("(No description)", dim=True))
149
+
150
+ click.echo("")
@@ -4,6 +4,7 @@ import os
4
4
  import shutil
5
5
  import subprocess
6
6
  import sys
7
+ import time
7
8
  from pathlib import Path
8
9
  from typing import Optional
9
10
 
@@ -16,7 +17,7 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
16
17
  """Run the launch command implementation.
17
18
 
18
19
  Args:
19
- session: Name of the tmux session.
20
+ session: Base name for the tmux sessions (will create {session}-conductor and {session}-workers).
20
21
  attach: Whether to attach to the session after creation.
21
22
  """
22
23
  # Check prerequisites
@@ -29,12 +30,24 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
29
30
  click.echo("Install Claude Code from: https://claude.ai/code")
30
31
  sys.exit(1)
31
32
 
32
- # Check if session already exists
33
- if _session_exists(session):
34
- click.echo(f"Session '{session}' already exists.")
35
- if attach:
36
- click.echo(f"Attaching to existing session...")
37
- _attach_session(session)
33
+ # Session names
34
+ conductor_session = f"{session}-conductor"
35
+ workers_session = f"{session}-workers"
36
+
37
+ # Check if sessions already exist
38
+ conductor_exists = _session_exists(conductor_session)
39
+ workers_exists = _session_exists(workers_session)
40
+
41
+ if conductor_exists or workers_exists:
42
+ click.echo("Existing Ensemble sessions found:")
43
+ if conductor_exists:
44
+ click.echo(f" - {conductor_session}")
45
+ if workers_exists:
46
+ click.echo(f" - {workers_session}")
47
+ click.echo("")
48
+ click.echo("To attach, open two terminal windows and run:")
49
+ click.echo(f" Terminal 1: tmux attach -t {conductor_session}")
50
+ click.echo(f" Terminal 2: tmux attach -t {workers_session}")
38
51
  return
39
52
 
40
53
  project_root = Path.cwd()
@@ -46,21 +59,58 @@ def run_launch(session: str = "ensemble", attach: bool = True) -> None:
46
59
  click.echo("Run 'ensemble init' first")
47
60
  sys.exit(1)
48
61
 
49
- click.echo(f"Launching Ensemble session '{session}'...")
62
+ click.echo(f"Launching Ensemble sessions '{session}-*'...")
63
+
64
+ # Clean up queue directories
65
+ queue_dir = project_root / "queue"
66
+ if queue_dir.exists():
67
+ for subdir in ["tasks", "processing", "reports", "ack"]:
68
+ subdir_path = queue_dir / subdir
69
+ if subdir_path.exists():
70
+ for f in subdir_path.glob("*.yaml"):
71
+ f.unlink()
72
+ for f in subdir_path.glob("*.ack"):
73
+ f.unlink()
74
+
75
+ # Ensure queue directories exist
76
+ for subdir in ["tasks", "processing", "reports", "ack", "conductor"]:
77
+ (queue_dir / subdir).mkdir(parents=True, exist_ok=True)
50
78
 
51
79
  # Get agent paths
52
80
  agents = _resolve_agent_paths(project_root)
53
81
 
54
- # Create tmux session with layout
55
- _create_session(session, project_root, agents)
82
+ # Create tmux sessions (2 separate sessions)
83
+ _create_sessions(session, project_root, agents)
56
84
 
57
85
  # Save pane IDs
58
86
  _save_pane_ids(session, ensemble_dir)
59
87
 
60
- click.echo(click.style("Ensemble session started!", fg="green"))
88
+ click.echo(click.style("Ensemble sessions started!", fg="green"))
89
+ click.echo("")
90
+ click.echo("==========================================")
91
+ click.echo(" Two separate tmux sessions created!")
92
+ click.echo("==========================================")
93
+ click.echo("")
94
+ click.echo(f"Session 1: {conductor_session}")
95
+ click.echo(" +------------------+------------------+")
96
+ click.echo(" | Conductor | dashboard |")
97
+ click.echo(" +------------------+------------------+")
98
+ click.echo("")
99
+ click.echo(f"Session 2: {workers_session}")
100
+ click.echo(" +------------------+------------------+")
101
+ click.echo(" | dispatch | worker-area |")
102
+ click.echo(" +------------------+------------------+")
103
+ click.echo("")
104
+ click.echo("To view both simultaneously, open two terminal windows:")
105
+ click.echo(f" Terminal 1: tmux attach -t {conductor_session}")
106
+ click.echo(f" Terminal 2: tmux attach -t {workers_session}")
107
+ click.echo("")
61
108
 
62
109
  if attach:
63
- _attach_session(session)
110
+ click.echo(f"Attaching to {conductor_session}...")
111
+ click.echo(f"(Open another terminal and run: tmux attach -t {workers_session})")
112
+ click.echo("")
113
+ _attach_session(conductor_session)
64
114
 
65
115
 
66
116
  def _check_tmux() -> bool:
@@ -125,93 +175,168 @@ def _resolve_agent_paths(project_root: Path) -> dict[str, Path]:
125
175
  return agents
126
176
 
127
177
 
128
- def _create_session(session: str, project_root: Path, agents: dict[str, Path]) -> None:
129
- """Create the tmux session with Ensemble layout."""
130
- # Create new session (detached)
178
+ def _create_sessions(session: str, project_root: Path, agents: dict[str, Path]) -> None:
179
+ """Create two separate tmux sessions for Ensemble.
180
+
181
+ Session 1 ({session}-conductor): Conductor (left 60%) + Dashboard (right 40%)
182
+ Session 2 ({session}-workers): Dispatch (left 60%) + Worker area (right 40%)
183
+
184
+ This allows viewing both sessions simultaneously in separate terminal windows.
185
+ """
186
+ conductor_session = f"{session}-conductor"
187
+ workers_session = f"{session}-workers"
188
+
189
+ # === Session 1: Conductor ===
131
190
  subprocess.run(
132
191
  [
133
192
  "tmux", "new-session",
134
193
  "-d",
135
- "-s", session,
194
+ "-s", conductor_session,
136
195
  "-c", str(project_root),
137
196
  "-n", "main",
138
197
  ],
139
198
  check=True,
140
199
  )
141
200
 
142
- # Split into 3 panes: Conductor (left), Dashboard (top-right), Dispatch (bottom-right)
143
- # First split vertically (creates pane 1 on right)
201
+ # Split conductor window: left/right (60/40)
144
202
  subprocess.run(
145
- ["tmux", "split-window", "-t", f"{session}:main", "-h", "-c", str(project_root)],
146
- check=True,
147
- )
148
-
149
- # Split the right pane horizontally (creates pane 2 at bottom-right)
150
- subprocess.run(
151
- ["tmux", "split-window", "-t", f"{session}:main.1", "-v", "-c", str(project_root)],
203
+ ["tmux", "split-window", "-t", f"{conductor_session}:main", "-h", "-l", "40%", "-c", str(project_root)],
152
204
  check=True,
153
205
  )
154
206
 
155
207
  # Set pane titles
156
- subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.0", "-T", "conductor"], check=True)
157
- subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.1", "-T", "dashboard"], check=True)
158
- subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.2", "-T", "dispatch"], check=True)
208
+ subprocess.run(["tmux", "select-pane", "-t", f"{conductor_session}:main.0", "-T", "conductor"], check=True)
209
+ subprocess.run(["tmux", "select-pane", "-t", f"{conductor_session}:main.1", "-T", "dashboard"], check=True)
159
210
 
160
- # Start Claude in Conductor pane (pane 0)
211
+ # Start Claude in Conductor pane (left)
161
212
  conductor_agent = agents.get("conductor")
162
213
  if conductor_agent:
163
- cmd = f"claude --agent {conductor_agent}"
214
+ cmd = f"MAX_THINKING_TOKENS=0 claude --agent {conductor_agent} --model opus --dangerously-skip-permissions"
164
215
  else:
165
- cmd = "claude"
216
+ cmd = "MAX_THINKING_TOKENS=0 claude --model opus --dangerously-skip-permissions"
217
+ subprocess.run(
218
+ ["tmux", "send-keys", "-t", f"{conductor_session}:main.0", cmd],
219
+ check=True,
220
+ )
221
+ time.sleep(1)
166
222
  subprocess.run(
167
- ["tmux", "send-keys", "-t", f"{session}:main.0", cmd, "Enter"],
223
+ ["tmux", "send-keys", "-t", f"{conductor_session}:main.0", "Enter"],
168
224
  check=True,
169
225
  )
170
226
 
171
- # Start dashboard watch in Dashboard pane (pane 1)
227
+ # Start dashboard watch in Dashboard pane (right)
172
228
  dashboard_path = project_root / ".ensemble" / "status" / "dashboard.md"
173
229
  subprocess.run(
174
- ["tmux", "send-keys", "-t", f"{session}:main.1", f"watch -n 2 cat {dashboard_path}", "Enter"],
230
+ ["tmux", "send-keys", "-t", f"{conductor_session}:main.1", f"watch -n 5 cat {dashboard_path}"],
175
231
  check=True,
176
232
  )
233
+ time.sleep(1)
234
+ subprocess.run(
235
+ ["tmux", "send-keys", "-t", f"{conductor_session}:main.1", "Enter"],
236
+ check=True,
237
+ )
238
+
239
+ # フレンドリーファイア防止: Conductorが起動完了するまで待機
240
+ time.sleep(3)
177
241
 
178
- # Start Claude in Dispatch pane (pane 2)
242
+ # === Session 2: Workers ===
243
+ subprocess.run(
244
+ [
245
+ "tmux", "new-session",
246
+ "-d",
247
+ "-s", workers_session,
248
+ "-c", str(project_root),
249
+ "-n", "main",
250
+ ],
251
+ check=True,
252
+ )
253
+
254
+ # Split workers window: left/right (60/40)
255
+ subprocess.run(
256
+ ["tmux", "split-window", "-t", f"{workers_session}:main", "-h", "-l", "40%", "-c", str(project_root)],
257
+ check=True,
258
+ )
259
+
260
+ # Set pane titles
261
+ subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.0", "-T", "dispatch"], check=True)
262
+ subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.1", "-T", "worker-area"], check=True)
263
+
264
+ # Start Claude in Dispatch pane (left)
179
265
  dispatch_agent = agents.get("dispatch")
180
266
  if dispatch_agent:
181
- cmd = f"claude --agent {dispatch_agent}"
267
+ cmd = f"claude --agent {dispatch_agent} --model sonnet --dangerously-skip-permissions"
182
268
  else:
183
- cmd = "claude"
269
+ cmd = "claude --model sonnet --dangerously-skip-permissions"
184
270
  subprocess.run(
185
- ["tmux", "send-keys", "-t", f"{session}:main.2", cmd, "Enter"],
271
+ ["tmux", "send-keys", "-t", f"{workers_session}:main.0", cmd],
272
+ check=True,
273
+ )
274
+ time.sleep(1)
275
+ subprocess.run(
276
+ ["tmux", "send-keys", "-t", f"{workers_session}:main.0", "Enter"],
186
277
  check=True,
187
278
  )
188
279
 
189
- # Select Conductor pane
190
- subprocess.run(["tmux", "select-pane", "-t", f"{session}:main.0"], check=True)
280
+ # Show placeholder message in worker area (right)
281
+ subprocess.run(
282
+ ["tmux", "send-keys", "-t", f"{workers_session}:main.1", "echo '=== Worker Area ===' && echo 'Workers will be started here.'", "Enter"],
283
+ check=True,
284
+ )
285
+
286
+ # Select dispatch pane in workers session
287
+ subprocess.run(["tmux", "select-pane", "-t", f"{workers_session}:main.0"], check=True)
191
288
 
192
289
 
193
290
  def _save_pane_ids(session: str, ensemble_dir: Path) -> None:
194
291
  """Save pane IDs to panes.env file."""
195
- # Get pane IDs
292
+ conductor_session = f"{session}-conductor"
293
+ workers_session = f"{session}-workers"
294
+
295
+ # Get conductor session pane IDs
296
+ result = subprocess.run(
297
+ ["tmux", "list-panes", "-t", f"{conductor_session}:main", "-F", "#{pane_index}:#{pane_id}"],
298
+ capture_output=True,
299
+ text=True,
300
+ check=True,
301
+ )
302
+
303
+ conductor_pane_map = {}
304
+ for line in result.stdout.strip().split("\n"):
305
+ if ":" in line:
306
+ idx, pane_id = line.split(":", 1)
307
+ conductor_pane_map[int(idx)] = pane_id
308
+
309
+ # Get workers session pane IDs
196
310
  result = subprocess.run(
197
- ["tmux", "list-panes", "-t", f"{session}:main", "-F", "#{pane_index}:#{pane_id}"],
311
+ ["tmux", "list-panes", "-t", f"{workers_session}:main", "-F", "#{pane_index}:#{pane_id}"],
198
312
  capture_output=True,
199
313
  text=True,
200
314
  check=True,
201
315
  )
202
316
 
203
- pane_map = {}
317
+ workers_pane_map = {}
204
318
  for line in result.stdout.strip().split("\n"):
205
319
  if ":" in line:
206
320
  idx, pane_id = line.split(":", 1)
207
- pane_map[int(idx)] = pane_id
321
+ workers_pane_map[int(idx)] = pane_id
208
322
 
209
323
  # Write panes.env
210
324
  panes_env = ensemble_dir / "panes.env"
211
325
  with open(panes_env, "w") as f:
212
326
  f.write("# Ensemble pane IDs (auto-generated)\n")
213
- f.write(f"CONDUCTOR_PANE={pane_map.get(0, '%0')}\n")
214
- f.write(f"DASHBOARD_PANE={pane_map.get(1, '%1')}\n")
215
- f.write(f"DISPATCH_PANE={pane_map.get(2, '%2')}\n")
327
+ f.write("# Session names\n")
328
+ f.write(f"CONDUCTOR_SESSION={conductor_session}\n")
329
+ f.write(f"WORKERS_SESSION={workers_session}\n")
330
+ f.write("\n")
331
+ f.write("# Pane IDs (use these with tmux send-keys -t)\n")
332
+ f.write(f"CONDUCTOR_PANE={conductor_pane_map.get(0, '%0')}\n")
333
+ f.write(f"DASHBOARD_PANE={conductor_pane_map.get(1, '%1')}\n")
334
+ f.write(f"DISPATCH_PANE={workers_pane_map.get(0, '%0')}\n")
335
+ f.write(f"WORKER_AREA_PANE={workers_pane_map.get(1, '%1')}\n")
336
+ f.write("\n")
337
+ f.write("# Usage examples:\n")
338
+ f.write("# source .ensemble/panes.env\n")
339
+ f.write("# tmux send-keys -t \"$CONDUCTOR_PANE\" 'message' Enter\n")
340
+ f.write("# tmux send-keys -t \"$DISPATCH_PANE\" 'message' Enter\n")
216
341
 
217
342
  click.echo(f" Saved pane IDs to {panes_env.relative_to(ensemble_dir.parent)}")
@@ -0,0 +1,36 @@
1
+ """Issue command for listing and viewing issues."""
2
+
3
+ import click
4
+
5
+
6
+ @click.command()
7
+ @click.argument("identifier", required=False)
8
+ @click.option(
9
+ "--state",
10
+ type=click.Choice(["open", "closed", "all"]),
11
+ default="open",
12
+ help="Filter issues by state.",
13
+ )
14
+ @click.option(
15
+ "--provider",
16
+ type=click.Choice(["github", "gitlab"]),
17
+ default="github",
18
+ help="Issue provider to use.",
19
+ )
20
+ def issue(identifier: str | None, state: str, provider: str) -> None:
21
+ """List issues or view a specific issue.
22
+
23
+ Without IDENTIFIER, lists all issues.
24
+ With IDENTIFIER, shows details of that issue (number or URL).
25
+
26
+ Examples:
27
+
28
+ ensemble issue # List open issues
29
+
30
+ ensemble issue 123 # View issue #123
31
+
32
+ ensemble issue --state all # List all issues
33
+ """
34
+ from ensemble.commands._issue_impl import run_issue
35
+
36
+ run_issue(identifier=identifier, state=state, provider=provider)
ensemble/git_utils.py ADDED
@@ -0,0 +1,157 @@
1
+ """Git utilities for branch and PR management."""
2
+
3
+ import re
4
+ import subprocess
5
+
6
+
7
+ def get_current_branch() -> str:
8
+ """Get the name of the current git branch.
9
+
10
+ Returns:
11
+ Current branch name.
12
+ """
13
+ result = subprocess.run(
14
+ ["git", "branch", "--show-current"],
15
+ capture_output=True,
16
+ text=True,
17
+ check=True,
18
+ )
19
+ return result.stdout.strip()
20
+
21
+
22
+ def is_working_tree_clean() -> bool:
23
+ """Check if the git working tree is clean.
24
+
25
+ Returns:
26
+ True if there are no uncommitted changes.
27
+ """
28
+ result = subprocess.run(
29
+ ["git", "status", "--porcelain"],
30
+ capture_output=True,
31
+ text=True,
32
+ )
33
+ return result.stdout.strip() == ""
34
+
35
+
36
+ def ensure_main_updated(base_branch: str = "main") -> None:
37
+ """Checkout the main branch and pull latest changes.
38
+
39
+ Args:
40
+ base_branch: Name of the base branch (default: main).
41
+
42
+ Raises:
43
+ RuntimeError: If checkout or pull fails.
44
+ """
45
+ # Checkout base branch
46
+ result = subprocess.run(
47
+ ["git", "checkout", base_branch],
48
+ capture_output=True,
49
+ text=True,
50
+ )
51
+
52
+ if result.returncode != 0:
53
+ raise RuntimeError(f"Failed to checkout {base_branch}: {result.stderr}")
54
+
55
+ # Pull latest
56
+ result = subprocess.run(
57
+ ["git", "pull", "origin", base_branch],
58
+ capture_output=True,
59
+ text=True,
60
+ )
61
+
62
+ if result.returncode != 0:
63
+ # Pull might fail if remote doesn't exist, which is OK for local repos
64
+ pass
65
+
66
+
67
+ def create_issue_branch(issue_number: int, title: str) -> str:
68
+ """Create a new branch for working on an issue.
69
+
70
+ Format: issue/<number>-<slugified-title>
71
+
72
+ Args:
73
+ issue_number: Issue number.
74
+ title: Issue title (will be slugified).
75
+
76
+ Returns:
77
+ Name of the created branch.
78
+
79
+ Raises:
80
+ RuntimeError: If branch creation fails.
81
+ """
82
+ # Slugify title
83
+ slug = title.lower()
84
+ slug = re.sub(r"[^a-z0-9\s-]", "", slug)
85
+ slug = re.sub(r"\s+", "-", slug)
86
+ slug = re.sub(r"-+", "-", slug)
87
+ slug = slug.strip("-")
88
+
89
+ # Truncate if too long
90
+ if len(slug) > 40:
91
+ slug = slug[:40].rsplit("-", 1)[0]
92
+
93
+ branch_name = f"issue/{issue_number}-{slug}"
94
+
95
+ # Try to create new branch
96
+ result = subprocess.run(
97
+ ["git", "checkout", "-b", branch_name],
98
+ capture_output=True,
99
+ text=True,
100
+ )
101
+
102
+ if result.returncode != 0:
103
+ # Branch might already exist, try to checkout
104
+ if "already exists" in result.stderr:
105
+ result = subprocess.run(
106
+ ["git", "checkout", branch_name],
107
+ capture_output=True,
108
+ text=True,
109
+ )
110
+ if result.returncode != 0:
111
+ raise RuntimeError(f"Failed to checkout branch {branch_name}: {result.stderr}")
112
+ else:
113
+ raise RuntimeError(f"Failed to create branch {branch_name}: {result.stderr}")
114
+
115
+ return branch_name
116
+
117
+
118
+ def create_pull_request(
119
+ title: str,
120
+ body: str,
121
+ issue_number: int | None = None,
122
+ ) -> str:
123
+ """Create a pull request using gh CLI.
124
+
125
+ Args:
126
+ title: PR title.
127
+ body: PR body/description.
128
+ issue_number: Optional issue number to link.
129
+
130
+ Returns:
131
+ URL of the created PR.
132
+
133
+ Raises:
134
+ RuntimeError: If PR creation fails.
135
+ """
136
+ # Build body with issue reference if provided
137
+ full_body = body
138
+ if issue_number:
139
+ if f"#{issue_number}" not in body and f"Closes #{issue_number}" not in body:
140
+ full_body = f"{body}\n\nCloses #{issue_number}"
141
+
142
+ cmd = [
143
+ "gh", "pr", "create",
144
+ "--title", title,
145
+ "--body", full_body,
146
+ ]
147
+
148
+ result = subprocess.run(
149
+ cmd,
150
+ capture_output=True,
151
+ text=True,
152
+ )
153
+
154
+ if result.returncode != 0:
155
+ raise RuntimeError(f"Failed to create pull request: {result.stderr}")
156
+
157
+ return result.stdout.strip()