soba-cli 0.1.0
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.
- checksums.yaml +7 -0
- data/.claude/commands/osoba/add-backlog.md +173 -0
- data/.claude/commands/osoba/implement.md +151 -0
- data/.claude/commands/osoba/plan.md +217 -0
- data/.claude/commands/osoba/review.md +133 -0
- data/.claude/commands/osoba/revise.md +176 -0
- data/.claude/commands/soba/implement.md +88 -0
- data/.claude/commands/soba/plan.md +93 -0
- data/.claude/commands/soba/review.md +91 -0
- data/.claude/commands/soba/revise.md +76 -0
- data/.devcontainer/.env +2 -0
- data/.devcontainer/Dockerfile +3 -0
- data/.devcontainer/LICENSE +21 -0
- data/.devcontainer/README.md +85 -0
- data/.devcontainer/bin/devcontainer-common.sh +50 -0
- data/.devcontainer/bin/down +35 -0
- data/.devcontainer/bin/rebuild +10 -0
- data/.devcontainer/bin/up +11 -0
- data/.devcontainer/compose.yaml +28 -0
- data/.devcontainer/devcontainer.json +53 -0
- data/.devcontainer/post-attach.sh +29 -0
- data/.devcontainer/post-create.sh +62 -0
- data/.devcontainer/setup/01-os-package.sh +19 -0
- data/.devcontainer/setup/02-npm-package.sh +22 -0
- data/.devcontainer/setup/03-mcp-server.sh +33 -0
- data/.devcontainer/setup/04-tool.sh +17 -0
- data/.devcontainer/setup/05-soba-setup.sh +66 -0
- data/.devcontainer/setup/scripts/functions/install_apt.sh +77 -0
- data/.devcontainer/setup/scripts/functions/install_npm.sh +71 -0
- data/.devcontainer/setup/scripts/functions/mcp_config.sh +14 -0
- data/.devcontainer/setup/scripts/functions/print_message.sh +59 -0
- data/.devcontainer/setup/scripts/setup/mcp-markdownify.sh +39 -0
- data/.devcontainer/sync-envs.sh +58 -0
- data/.envrc.sample +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +70 -0
- data/.rubocop_airbnb.yml +2 -0
- data/.rubocop_todo.yml +74 -0
- data/.tool-versions +1 -0
- data/CLAUDE.md +20 -0
- data/LICENSE +21 -0
- data/README.md +384 -0
- data/README_ja.md +384 -0
- data/Rakefile +18 -0
- data/bin/soba +120 -0
- data/config/config.yml.example +36 -0
- data/docs/business/INDEX.md +6 -0
- data/docs/business/overview.md +42 -0
- data/docs/business/workflow.md +143 -0
- data/docs/development/INDEX.md +10 -0
- data/docs/development/architecture.md +69 -0
- data/docs/development/coding-standards.md +152 -0
- data/docs/development/distribution.md +26 -0
- data/docs/development/implementation-guide.md +103 -0
- data/docs/development/testing-strategy.md +128 -0
- data/docs/development/tmux-management.md +253 -0
- data/docs/document_system.md +58 -0
- data/lib/soba/commands/config/show.rb +63 -0
- data/lib/soba/commands/init.rb +778 -0
- data/lib/soba/commands/open.rb +144 -0
- data/lib/soba/commands/start.rb +442 -0
- data/lib/soba/commands/status.rb +175 -0
- data/lib/soba/commands/stop.rb +147 -0
- data/lib/soba/config_loader.rb +32 -0
- data/lib/soba/configuration.rb +268 -0
- data/lib/soba/container.rb +48 -0
- data/lib/soba/domain/issue.rb +38 -0
- data/lib/soba/domain/phase_strategy.rb +74 -0
- data/lib/soba/infrastructure/errors.rb +23 -0
- data/lib/soba/infrastructure/github_client.rb +399 -0
- data/lib/soba/infrastructure/lock_manager.rb +129 -0
- data/lib/soba/infrastructure/tmux_client.rb +331 -0
- data/lib/soba/services/ansi_processor.rb +92 -0
- data/lib/soba/services/auto_merge_service.rb +133 -0
- data/lib/soba/services/closed_issue_window_cleaner.rb +96 -0
- data/lib/soba/services/daemon_service.rb +83 -0
- data/lib/soba/services/git_workspace_manager.rb +102 -0
- data/lib/soba/services/issue_monitor.rb +29 -0
- data/lib/soba/services/issue_processor.rb +215 -0
- data/lib/soba/services/issue_watcher.rb +193 -0
- data/lib/soba/services/pid_manager.rb +87 -0
- data/lib/soba/services/process_info.rb +58 -0
- data/lib/soba/services/queueing_service.rb +98 -0
- data/lib/soba/services/session_logger.rb +111 -0
- data/lib/soba/services/session_resolver.rb +72 -0
- data/lib/soba/services/slack_notifier.rb +121 -0
- data/lib/soba/services/status_manager.rb +74 -0
- data/lib/soba/services/test_process_manager.rb +84 -0
- data/lib/soba/services/tmux_session_manager.rb +251 -0
- data/lib/soba/services/workflow_blocking_checker.rb +73 -0
- data/lib/soba/services/workflow_executor.rb +256 -0
- data/lib/soba/services/workflow_integrity_checker.rb +151 -0
- data/lib/soba/templates/claude_commands/implement.md +88 -0
- data/lib/soba/templates/claude_commands/plan.md +93 -0
- data/lib/soba/templates/claude_commands/review.md +91 -0
- data/lib/soba/templates/claude_commands/revise.md +76 -0
- data/lib/soba/version.rb +5 -0
- data/lib/soba.rb +44 -0
- data/lib/tasks/gem.rake +75 -0
- data/soba-cli.gemspec +59 -0
- metadata +430 -0
@@ -0,0 +1,251 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object/blank'
|
4
|
+
require_relative '../configuration'
|
5
|
+
require_relative '../infrastructure/lock_manager'
|
6
|
+
require_relative '../infrastructure/tmux_client'
|
7
|
+
require_relative 'test_process_manager'
|
8
|
+
|
9
|
+
module Soba
|
10
|
+
module Services
|
11
|
+
class TmuxSessionManager
|
12
|
+
def initialize(config: nil, tmux_client: nil, lock_manager: nil, test_process_manager: nil)
|
13
|
+
@config = config
|
14
|
+
@tmux_client = tmux_client || Soba::Infrastructure::TmuxClient.new
|
15
|
+
@lock_manager = lock_manager || Soba::Infrastructure::LockManager.new
|
16
|
+
@test_process_manager = test_process_manager || TestProcessManager.new
|
17
|
+
end
|
18
|
+
|
19
|
+
def find_or_create_repository_session
|
20
|
+
repository = Configuration.config.github.repository
|
21
|
+
|
22
|
+
return { success: false, error: 'Repository configuration not found' } if repository.blank?
|
23
|
+
|
24
|
+
# Convert repository name to session-safe format with PID
|
25
|
+
session_name = generate_session_name(repository)
|
26
|
+
|
27
|
+
if @tmux_client.session_exists?(session_name)
|
28
|
+
{ success: true, session_name: session_name, created: false }
|
29
|
+
else
|
30
|
+
if @tmux_client.create_session(session_name)
|
31
|
+
{ success: true, session_name: session_name, created: true }
|
32
|
+
else
|
33
|
+
{ success: false, error: 'Failed to create repository session' }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def find_repository_session
|
39
|
+
repository = Configuration.config.github.repository
|
40
|
+
|
41
|
+
return { success: false, error: 'Repository configuration not found' } if repository.blank?
|
42
|
+
|
43
|
+
# Convert repository name to session-safe format with PID
|
44
|
+
session_name = generate_session_name(repository)
|
45
|
+
|
46
|
+
if @tmux_client.session_exists?(session_name)
|
47
|
+
{ success: true, session_name: session_name, exists: true }
|
48
|
+
else
|
49
|
+
{ success: true, session_name: session_name, exists: false }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def create_issue_window(session_name:, issue_number:)
|
54
|
+
window_name = "issue-#{issue_number}"
|
55
|
+
lock_name = "window-#{session_name}-#{window_name}"
|
56
|
+
|
57
|
+
@lock_manager.with_lock(lock_name, timeout: 5) do
|
58
|
+
# Double check for existing window to prevent duplicates
|
59
|
+
if @tmux_client.window_exists?(session_name, window_name)
|
60
|
+
{ success: true, window_name: window_name, created: false }
|
61
|
+
else
|
62
|
+
if @tmux_client.create_window(session_name, window_name)
|
63
|
+
# Verify creation was successful
|
64
|
+
if @tmux_client.window_exists?(session_name, window_name)
|
65
|
+
{ success: true, window_name: window_name, created: true }
|
66
|
+
else
|
67
|
+
{ success: false, error: "Window creation verification failed for issue #{issue_number}" }
|
68
|
+
end
|
69
|
+
else
|
70
|
+
{ success: false, error: "Failed to create window for issue #{issue_number}" }
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
rescue Soba::Infrastructure::LockTimeoutError => e
|
75
|
+
{ success: false, error: "Lock acquisition failed: #{e.message}" }
|
76
|
+
end
|
77
|
+
|
78
|
+
def create_phase_pane(session_name:, window_name:, phase:, vertical: true, max_panes: 3, max_retries: 3)
|
79
|
+
# 前提条件チェック
|
80
|
+
unless @tmux_client.session_exists?(session_name)
|
81
|
+
return { success: false, error: "Session does not exist: #{session_name}" }
|
82
|
+
end
|
83
|
+
|
84
|
+
unless @tmux_client.window_exists?(session_name, window_name)
|
85
|
+
return { success: false, error: "Window does not exist: #{window_name}" }
|
86
|
+
end
|
87
|
+
|
88
|
+
# tmuxサーバーの応答性チェック
|
89
|
+
if @tmux_client.list_sessions.nil?
|
90
|
+
return { success: false, error: 'tmux server is not responding' }
|
91
|
+
end
|
92
|
+
|
93
|
+
# 現在のペイン一覧を取得
|
94
|
+
existing_panes = @tmux_client.list_panes(session_name, window_name)
|
95
|
+
|
96
|
+
# 最大ペイン数を超えている場合、古いペインを削除
|
97
|
+
if existing_panes.size >= max_panes
|
98
|
+
# start_timeでソート(古い順)
|
99
|
+
sorted_panes = existing_panes.sort_by { |p| p[:start_time] }
|
100
|
+
|
101
|
+
# 最大ペイン数-1になるまで古いペインを削除
|
102
|
+
panes_to_remove = sorted_panes.take(existing_panes.size - max_panes + 1)
|
103
|
+
panes_to_remove.each do |pane|
|
104
|
+
@tmux_client.kill_pane(pane[:id])
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# リトライロジック付きでペインを作成
|
109
|
+
pane_id = nil
|
110
|
+
error_details = nil
|
111
|
+
retry_count = 0
|
112
|
+
retry_delays = [0.5, 1, 2] # 指数バックオフ
|
113
|
+
|
114
|
+
max_retries.times do |attempt|
|
115
|
+
result = @tmux_client.split_window(
|
116
|
+
session_name: session_name,
|
117
|
+
window_name: window_name,
|
118
|
+
vertical: vertical
|
119
|
+
)
|
120
|
+
|
121
|
+
# 結果を確認
|
122
|
+
if result.is_a?(Array)
|
123
|
+
pane_id, error_details = result
|
124
|
+
else
|
125
|
+
pane_id = result
|
126
|
+
end
|
127
|
+
|
128
|
+
if pane_id
|
129
|
+
# 成功した場合はループを抜ける
|
130
|
+
break
|
131
|
+
else
|
132
|
+
retry_count = attempt + 1
|
133
|
+
if error_details && retry_count < max_retries
|
134
|
+
Soba.logger.warn(
|
135
|
+
"Pane creation failed (attempt #{retry_count}/#{max_retries}): " \
|
136
|
+
"#{error_details[:stderr]} (exit status: #{error_details[:exit_status]})"
|
137
|
+
)
|
138
|
+
sleep(retry_delays[attempt] || 2)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
if pane_id
|
144
|
+
# レイアウトを調整
|
145
|
+
@tmux_client.select_layout(session_name, window_name, 'even-horizontal')
|
146
|
+
|
147
|
+
{ success: true, pane_id: pane_id, phase: phase }
|
148
|
+
else
|
149
|
+
error_message = "Failed to create pane for phase #{phase}"
|
150
|
+
if error_details
|
151
|
+
error_message += ": #{error_details[:stderr]}"
|
152
|
+
Soba.logger.error(
|
153
|
+
"Pane creation failed after #{retry_count} retries: " \
|
154
|
+
"#{error_details[:stderr]} (exit status: #{error_details[:exit_status]})"
|
155
|
+
)
|
156
|
+
end
|
157
|
+
{ success: false, error: error_message }
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def find_issue_window(repository_name, issue_number)
|
162
|
+
session_name = generate_session_name(repository_name)
|
163
|
+
window_name = "issue-#{issue_number}"
|
164
|
+
|
165
|
+
if @tmux_client.session_exists?(session_name) && @tmux_client.window_exists?(session_name, window_name)
|
166
|
+
"#{session_name}:#{window_name}"
|
167
|
+
else
|
168
|
+
nil
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def list_issue_windows(repository_name)
|
173
|
+
session_name = generate_session_name(repository_name)
|
174
|
+
|
175
|
+
return [] unless @tmux_client.session_exists?(session_name)
|
176
|
+
|
177
|
+
windows = @tmux_client.list_windows(session_name)
|
178
|
+
issue_windows = windows.select { |window| window.start_with?('issue-') }
|
179
|
+
|
180
|
+
issue_windows.map do |window|
|
181
|
+
issue_number = begin
|
182
|
+
window.match(/issue-(\d+)/)[1]
|
183
|
+
rescue
|
184
|
+
nil
|
185
|
+
end
|
186
|
+
next unless issue_number
|
187
|
+
|
188
|
+
# Try to fetch issue title from GitHub
|
189
|
+
title = begin
|
190
|
+
fetch_issue_title(repository_name, issue_number)
|
191
|
+
rescue
|
192
|
+
nil
|
193
|
+
end
|
194
|
+
|
195
|
+
{
|
196
|
+
window: window,
|
197
|
+
title: title,
|
198
|
+
}
|
199
|
+
end.compact
|
200
|
+
end
|
201
|
+
|
202
|
+
# Check if a session exists
|
203
|
+
def session_exists?(session_name)
|
204
|
+
@tmux_client.session_exists?(session_name)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Find repository session by PID
|
208
|
+
def find_repository_session_by_pid(repository)
|
209
|
+
require_relative 'pid_manager'
|
210
|
+
|
211
|
+
# Get PID file path for this repository
|
212
|
+
pid_file = File.join(Dir.home, '.soba', 'pids', "#{repository.gsub('/', '-')}.pid")
|
213
|
+
pid_manager = PidManager.new(pid_file)
|
214
|
+
|
215
|
+
pid = pid_manager.read
|
216
|
+
|
217
|
+
unless pid
|
218
|
+
return { success: true, session_name: nil, exists: false }
|
219
|
+
end
|
220
|
+
|
221
|
+
# Generate session name with PID
|
222
|
+
session_name = "soba-#{repository.gsub(/[\/._]/, '-')}-#{pid}"
|
223
|
+
|
224
|
+
if @tmux_client.session_exists?(session_name)
|
225
|
+
{ success: true, session_name: session_name, exists: true }
|
226
|
+
else
|
227
|
+
# Clean up stale PID file
|
228
|
+
pid_manager.delete
|
229
|
+
{ success: true, session_name: nil, exists: false }
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
private
|
234
|
+
|
235
|
+
# Generate session name without PID for simplicity
|
236
|
+
def generate_session_name(repository)
|
237
|
+
if @test_process_manager.test_mode?
|
238
|
+
@test_process_manager.generate_test_session_name(repository)
|
239
|
+
else
|
240
|
+
"soba-#{repository.gsub(/[\/._]/, '-')}"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
def fetch_issue_title(repository_name, issue_number)
|
245
|
+
# This is a placeholder - actual implementation would use GitHub API
|
246
|
+
# For now, return nil to let the command handle it
|
247
|
+
nil
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soba
|
4
|
+
module Services
|
5
|
+
class WorkflowBlockingChecker
|
6
|
+
ACTIVE_LABELS = %w(
|
7
|
+
soba:queued
|
8
|
+
soba:planning
|
9
|
+
soba:ready
|
10
|
+
soba:doing
|
11
|
+
soba:reviewing
|
12
|
+
soba:revising
|
13
|
+
).freeze
|
14
|
+
|
15
|
+
INTERMEDIATE_LABELS = %w(
|
16
|
+
soba:review-requested
|
17
|
+
soba:requires-changes
|
18
|
+
soba:done
|
19
|
+
soba:merged
|
20
|
+
).freeze
|
21
|
+
|
22
|
+
attr_reader :github_client, :logger
|
23
|
+
|
24
|
+
def initialize(github_client:, logger: nil)
|
25
|
+
@github_client = github_client
|
26
|
+
@logger = logger || SemanticLogger["WorkflowBlockingChecker"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def blocking?(repository, issues:, except_issue_number: nil)
|
30
|
+
!blocking_issues(repository, issues: issues, except_issue_number: except_issue_number).empty?
|
31
|
+
end
|
32
|
+
|
33
|
+
def blocking_issues(repository, issues:, except_issue_number: nil)
|
34
|
+
# 引数で渡されたissuesからACTIVE_LABELSまたはINTERMEDIATE_LABELSを持つものを検出
|
35
|
+
# except_issue_numberが指定されている場合は、そのissueを除外
|
36
|
+
blocking = issues.select do |issue|
|
37
|
+
if except_issue_number && issue.number == except_issue_number
|
38
|
+
next false
|
39
|
+
end
|
40
|
+
|
41
|
+
issue.labels.any? do |label|
|
42
|
+
label_name = label.is_a?(Hash) ? label[:name] : label.name
|
43
|
+
ACTIVE_LABELS.include?(label_name) || INTERMEDIATE_LABELS.include?(label_name)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
logger&.debug("Found #{blocking.size} blocking issues with ACTIVE_LABELS or INTERMEDIATE_LABELS")
|
48
|
+
blocking.each do |issue|
|
49
|
+
labels = issue.labels.map { |l| l.is_a?(Hash) ? l[:name] : l.name }.
|
50
|
+
select { |n| ACTIVE_LABELS.include?(n) || INTERMEDIATE_LABELS.include?(n) }
|
51
|
+
logger&.debug("Issue ##{issue.number}: #{labels.join(', ')}")
|
52
|
+
end
|
53
|
+
|
54
|
+
blocking.compact.uniq { |issue| issue.number }
|
55
|
+
end
|
56
|
+
|
57
|
+
def blocking_reason(repository, issues:, except_issue_number: nil)
|
58
|
+
blocking = blocking_issues(repository, issues: issues, except_issue_number: except_issue_number)
|
59
|
+
return nil if blocking.empty?
|
60
|
+
|
61
|
+
issue = blocking.first
|
62
|
+
label = issue.labels.find do |l|
|
63
|
+
label_name = l.is_a?(Hash) ? l[:name] : l.name
|
64
|
+
ACTIVE_LABELS.include?(label_name) || INTERMEDIATE_LABELS.include?(label_name)
|
65
|
+
end
|
66
|
+
return nil unless label
|
67
|
+
|
68
|
+
label_name = label.is_a?(Hash) ? label[:name] : label.name
|
69
|
+
"Issue ##{issue.number} が #{label_name} のため、新しいワークフローの開始をスキップしました"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,256 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require 'shellwords'
|
5
|
+
require_relative 'git_workspace_manager'
|
6
|
+
require_relative 'slack_notifier'
|
7
|
+
require_relative '../configuration'
|
8
|
+
require_relative '../config_loader'
|
9
|
+
|
10
|
+
module Soba
|
11
|
+
module Services
|
12
|
+
class WorkflowExecutionError < StandardError; end
|
13
|
+
|
14
|
+
class WorkflowExecutor
|
15
|
+
def initialize(tmux_session_manager: nil, git_workspace_manager: nil)
|
16
|
+
@tmux_session_manager = tmux_session_manager
|
17
|
+
@git_workspace_manager = git_workspace_manager || GitWorkspaceManager.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def execute(phase:, issue_number:, use_tmux: true, setup_workspace: true, issue_title: nil, phase_name: nil)
|
21
|
+
return nil unless phase.command
|
22
|
+
|
23
|
+
# Slack通知を送信(設定が有効な場合)
|
24
|
+
send_slack_notification(issue_number, issue_title, phase_name) if phase_name
|
25
|
+
|
26
|
+
# フェーズ開始時にmainブランチを更新し、ワークスペースをセットアップ
|
27
|
+
if setup_workspace
|
28
|
+
# mainブランチを最新化
|
29
|
+
begin
|
30
|
+
@git_workspace_manager.update_main_branch
|
31
|
+
puts "Successfully updated main branch"
|
32
|
+
rescue GitWorkspaceManager::GitOperationError => e
|
33
|
+
puts "Warning: Failed to update main branch: #{e.message}"
|
34
|
+
puts " Continuing without main branch update..."
|
35
|
+
# mainブランチの更新に失敗しても続行(エラーハンドリング)
|
36
|
+
end
|
37
|
+
|
38
|
+
# ワークスペースをセットアップ
|
39
|
+
begin
|
40
|
+
@git_workspace_manager.setup_workspace(issue_number)
|
41
|
+
puts "Successfully setup workspace for issue ##{issue_number}"
|
42
|
+
rescue GitWorkspaceManager::GitOperationError => e
|
43
|
+
puts "Warning: Failed to setup workspace: #{e.message}"
|
44
|
+
puts " Continuing without worktree setup..."
|
45
|
+
# ワークスペースのセットアップに失敗しても続行(既存の動作を維持)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
if use_tmux
|
50
|
+
execute_in_tmux(phase: phase, issue_number: issue_number)
|
51
|
+
else
|
52
|
+
execute_direct(phase: phase, issue_number: issue_number)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def execute_direct(phase:, issue_number:)
|
57
|
+
return nil unless phase.command
|
58
|
+
|
59
|
+
command_array = build_command(phase, issue_number)
|
60
|
+
worktree_path = @git_workspace_manager.get_worktree_path(issue_number)
|
61
|
+
|
62
|
+
result = if worktree_path
|
63
|
+
# worktreeが存在する場合はその中で実行
|
64
|
+
Dir.chdir(worktree_path) do
|
65
|
+
Open3.popen3(*command_array) do |stdin, stdout, stderr, wait_thr|
|
66
|
+
stdin.close
|
67
|
+
[stdout.read, stderr.read, wait_thr.value]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
else
|
71
|
+
# worktreeが存在しない場合は現在のディレクトリで実行
|
72
|
+
Open3.popen3(*command_array) do |stdin, stdout, stderr, wait_thr|
|
73
|
+
stdin.close
|
74
|
+
[stdout.read, stderr.read, wait_thr.value]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
stdout, stderr, status = result
|
79
|
+
|
80
|
+
{
|
81
|
+
success: status.exitstatus == 0,
|
82
|
+
output: stdout,
|
83
|
+
error: stderr,
|
84
|
+
exit_code: status.exitstatus,
|
85
|
+
}
|
86
|
+
rescue Errno::ENOENT => e
|
87
|
+
raise WorkflowExecutionError, "Failed to execute workflow command: #{e.message}"
|
88
|
+
rescue StandardError => e
|
89
|
+
raise WorkflowExecutionError, "Failed to execute workflow command: #{e.message}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def execute_in_tmux(phase:, issue_number:)
|
93
|
+
return nil unless phase.command
|
94
|
+
|
95
|
+
command_string = build_command_string_with_worktree(phase, issue_number)
|
96
|
+
puts "Executing in tmux for phase: #{phase.name || 'unknown'}, issue ##{issue_number}"
|
97
|
+
|
98
|
+
begin
|
99
|
+
# 新しいtmux管理方式: 1リポジトリ = 1セッション、1 Issue = 1 window
|
100
|
+
session_result = @tmux_session_manager.find_or_create_repository_session
|
101
|
+
return session_result unless session_result[:success]
|
102
|
+
|
103
|
+
window_result = @tmux_session_manager.create_issue_window(
|
104
|
+
session_name: session_result[:session_name],
|
105
|
+
issue_number: issue_number
|
106
|
+
)
|
107
|
+
return window_result unless window_result[:success]
|
108
|
+
|
109
|
+
# フェーズごとにpane分割(既存のwindowがある場合は新規pane作成)
|
110
|
+
if window_result[:created]
|
111
|
+
# 新規windowの場合は最初のpaneでコマンド実行
|
112
|
+
puts " Created new window: #{window_result[:window_name]}"
|
113
|
+
apply_command_delay
|
114
|
+
tmux_client = Soba::Infrastructure::TmuxClient.new
|
115
|
+
tmux_client.send_keys("#{session_result[:session_name]}:#{window_result[:window_name]}", command_string)
|
116
|
+
pane_id = nil
|
117
|
+
else
|
118
|
+
# 既存windowの場合は新規paneを作成(水平分割)
|
119
|
+
phase_name = phase.name || 'unknown'
|
120
|
+
puts " Creating new pane for phase: #{phase_name} in window: #{window_result[:window_name]}"
|
121
|
+
pane_result = @tmux_session_manager.create_phase_pane(
|
122
|
+
session_name: session_result[:session_name],
|
123
|
+
window_name: window_result[:window_name],
|
124
|
+
phase: phase_name,
|
125
|
+
vertical: false
|
126
|
+
)
|
127
|
+
return pane_result unless pane_result[:success]
|
128
|
+
|
129
|
+
apply_command_delay
|
130
|
+
pane_id = pane_result[:pane_id]
|
131
|
+
puts " Created pane: #{pane_id}"
|
132
|
+
tmux_client = Soba::Infrastructure::TmuxClient.new
|
133
|
+
tmux_client.send_keys(pane_id, command_string)
|
134
|
+
end
|
135
|
+
|
136
|
+
# 監視用コマンドを生成
|
137
|
+
target = pane_id || "#{session_result[:session_name]}:#{window_result[:window_name]}"
|
138
|
+
monitor_commands = [
|
139
|
+
"tmux attach -t #{target}",
|
140
|
+
"tmux capture-pane -t #{target} -p",
|
141
|
+
]
|
142
|
+
|
143
|
+
{
|
144
|
+
success: true,
|
145
|
+
session_name: session_result[:session_name],
|
146
|
+
window_name: window_result[:window_name],
|
147
|
+
pane_id: pane_id,
|
148
|
+
mode: 'tmux',
|
149
|
+
tmux_info: {
|
150
|
+
session: session_result[:session_name],
|
151
|
+
window: window_result[:window_name],
|
152
|
+
pane: pane_id,
|
153
|
+
monitor_commands: monitor_commands,
|
154
|
+
},
|
155
|
+
}
|
156
|
+
rescue Soba::Infrastructure::TmuxNotInstalled => e
|
157
|
+
# tmuxがインストールされていない場合は通常実行にフォールバック
|
158
|
+
puts "Warning: #{e.message}. Falling back to direct execution..."
|
159
|
+
execute_direct(phase: phase, issue_number: issue_number)
|
160
|
+
rescue StandardError => e
|
161
|
+
# その他のtmuxエラーの場合も通常実行にフォールバック
|
162
|
+
puts "Warning: Tmux execution failed: #{e.message}. Falling back to direct execution..."
|
163
|
+
execute_direct(phase: phase, issue_number: issue_number)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def send_slack_notification(issue_number, issue_title, phase_name)
|
170
|
+
return unless Configuration.config.slack.notifications_enabled
|
171
|
+
|
172
|
+
notifier = SlackNotifier.from_config
|
173
|
+
return unless notifier&.enabled?
|
174
|
+
|
175
|
+
Soba.logger.debug "Sending Slack notification for phase '#{phase_name}' of issue ##{issue_number}"
|
176
|
+
|
177
|
+
result = notifier.notify_phase_start(
|
178
|
+
number: issue_number,
|
179
|
+
title: issue_title || "Issue ##{issue_number}",
|
180
|
+
phase: phase_name,
|
181
|
+
repository: Configuration.config.github.repository
|
182
|
+
)
|
183
|
+
|
184
|
+
if result
|
185
|
+
Soba.logger.debug "Successfully sent Slack notification for phase '#{phase_name}'"
|
186
|
+
else
|
187
|
+
Soba.logger.debug "Failed to send Slack notification for phase '#{phase_name}'"
|
188
|
+
end
|
189
|
+
rescue StandardError => e
|
190
|
+
Soba.logger.warn "Failed to send Slack notification: #{e.message}"
|
191
|
+
end
|
192
|
+
|
193
|
+
def build_command(phase_config, issue_number)
|
194
|
+
command = [phase_config.command]
|
195
|
+
command.concat(phase_config.options) if phase_config.options&.any?
|
196
|
+
|
197
|
+
if phase_config.parameter
|
198
|
+
parameter = phase_config.parameter.gsub('{{issue-number}}', issue_number.to_s)
|
199
|
+
command << parameter
|
200
|
+
end
|
201
|
+
|
202
|
+
command
|
203
|
+
end
|
204
|
+
|
205
|
+
def build_command_string(phase_config, issue_number)
|
206
|
+
command_parts = build_command(phase_config, issue_number)
|
207
|
+
|
208
|
+
# コマンドは最初の要素
|
209
|
+
result = [command_parts[0]]
|
210
|
+
|
211
|
+
# オプションが存在する場合(コマンド、オプション、パラメータの3つ以上の要素がある場合)
|
212
|
+
if phase_config.options&.any?
|
213
|
+
result.concat(phase_config.options)
|
214
|
+
end
|
215
|
+
|
216
|
+
# パラメータがある場合はダブルクォートで囲む
|
217
|
+
if phase_config.parameter
|
218
|
+
parameter = phase_config.parameter.gsub('{{issue-number}}', issue_number.to_s)
|
219
|
+
# パラメータにスペースやスラッシュが含まれる場合はダブルクォートで囲む
|
220
|
+
if parameter.include?(' ') || parameter.include?('/')
|
221
|
+
result << "\"#{parameter}\""
|
222
|
+
else
|
223
|
+
result << parameter
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
result.join(' ')
|
228
|
+
end
|
229
|
+
|
230
|
+
def build_command_string_with_worktree(phase_config, issue_number)
|
231
|
+
command_string = build_command_string(phase_config, issue_number)
|
232
|
+
worktree_path = @git_workspace_manager.get_worktree_path(issue_number)
|
233
|
+
|
234
|
+
if worktree_path
|
235
|
+
"cd #{worktree_path} && #{command_string}"
|
236
|
+
else
|
237
|
+
command_string
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def apply_command_delay
|
242
|
+
begin
|
243
|
+
config = Soba::Configuration.load!
|
244
|
+
delay = config&.workflow&.tmux_command_delay
|
245
|
+
delay = 3 if delay.nil?
|
246
|
+
rescue StandardError
|
247
|
+
delay = 3
|
248
|
+
end
|
249
|
+
|
250
|
+
if delay && delay > 0
|
251
|
+
sleep(delay)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|