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.
Files changed (101) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/osoba/add-backlog.md +173 -0
  3. data/.claude/commands/osoba/implement.md +151 -0
  4. data/.claude/commands/osoba/plan.md +217 -0
  5. data/.claude/commands/osoba/review.md +133 -0
  6. data/.claude/commands/osoba/revise.md +176 -0
  7. data/.claude/commands/soba/implement.md +88 -0
  8. data/.claude/commands/soba/plan.md +93 -0
  9. data/.claude/commands/soba/review.md +91 -0
  10. data/.claude/commands/soba/revise.md +76 -0
  11. data/.devcontainer/.env +2 -0
  12. data/.devcontainer/Dockerfile +3 -0
  13. data/.devcontainer/LICENSE +21 -0
  14. data/.devcontainer/README.md +85 -0
  15. data/.devcontainer/bin/devcontainer-common.sh +50 -0
  16. data/.devcontainer/bin/down +35 -0
  17. data/.devcontainer/bin/rebuild +10 -0
  18. data/.devcontainer/bin/up +11 -0
  19. data/.devcontainer/compose.yaml +28 -0
  20. data/.devcontainer/devcontainer.json +53 -0
  21. data/.devcontainer/post-attach.sh +29 -0
  22. data/.devcontainer/post-create.sh +62 -0
  23. data/.devcontainer/setup/01-os-package.sh +19 -0
  24. data/.devcontainer/setup/02-npm-package.sh +22 -0
  25. data/.devcontainer/setup/03-mcp-server.sh +33 -0
  26. data/.devcontainer/setup/04-tool.sh +17 -0
  27. data/.devcontainer/setup/05-soba-setup.sh +66 -0
  28. data/.devcontainer/setup/scripts/functions/install_apt.sh +77 -0
  29. data/.devcontainer/setup/scripts/functions/install_npm.sh +71 -0
  30. data/.devcontainer/setup/scripts/functions/mcp_config.sh +14 -0
  31. data/.devcontainer/setup/scripts/functions/print_message.sh +59 -0
  32. data/.devcontainer/setup/scripts/setup/mcp-markdownify.sh +39 -0
  33. data/.devcontainer/sync-envs.sh +58 -0
  34. data/.envrc.sample +7 -0
  35. data/.rspec +4 -0
  36. data/.rubocop.yml +70 -0
  37. data/.rubocop_airbnb.yml +2 -0
  38. data/.rubocop_todo.yml +74 -0
  39. data/.tool-versions +1 -0
  40. data/CLAUDE.md +20 -0
  41. data/LICENSE +21 -0
  42. data/README.md +384 -0
  43. data/README_ja.md +384 -0
  44. data/Rakefile +18 -0
  45. data/bin/soba +120 -0
  46. data/config/config.yml.example +36 -0
  47. data/docs/business/INDEX.md +6 -0
  48. data/docs/business/overview.md +42 -0
  49. data/docs/business/workflow.md +143 -0
  50. data/docs/development/INDEX.md +10 -0
  51. data/docs/development/architecture.md +69 -0
  52. data/docs/development/coding-standards.md +152 -0
  53. data/docs/development/distribution.md +26 -0
  54. data/docs/development/implementation-guide.md +103 -0
  55. data/docs/development/testing-strategy.md +128 -0
  56. data/docs/development/tmux-management.md +253 -0
  57. data/docs/document_system.md +58 -0
  58. data/lib/soba/commands/config/show.rb +63 -0
  59. data/lib/soba/commands/init.rb +778 -0
  60. data/lib/soba/commands/open.rb +144 -0
  61. data/lib/soba/commands/start.rb +442 -0
  62. data/lib/soba/commands/status.rb +175 -0
  63. data/lib/soba/commands/stop.rb +147 -0
  64. data/lib/soba/config_loader.rb +32 -0
  65. data/lib/soba/configuration.rb +268 -0
  66. data/lib/soba/container.rb +48 -0
  67. data/lib/soba/domain/issue.rb +38 -0
  68. data/lib/soba/domain/phase_strategy.rb +74 -0
  69. data/lib/soba/infrastructure/errors.rb +23 -0
  70. data/lib/soba/infrastructure/github_client.rb +399 -0
  71. data/lib/soba/infrastructure/lock_manager.rb +129 -0
  72. data/lib/soba/infrastructure/tmux_client.rb +331 -0
  73. data/lib/soba/services/ansi_processor.rb +92 -0
  74. data/lib/soba/services/auto_merge_service.rb +133 -0
  75. data/lib/soba/services/closed_issue_window_cleaner.rb +96 -0
  76. data/lib/soba/services/daemon_service.rb +83 -0
  77. data/lib/soba/services/git_workspace_manager.rb +102 -0
  78. data/lib/soba/services/issue_monitor.rb +29 -0
  79. data/lib/soba/services/issue_processor.rb +215 -0
  80. data/lib/soba/services/issue_watcher.rb +193 -0
  81. data/lib/soba/services/pid_manager.rb +87 -0
  82. data/lib/soba/services/process_info.rb +58 -0
  83. data/lib/soba/services/queueing_service.rb +98 -0
  84. data/lib/soba/services/session_logger.rb +111 -0
  85. data/lib/soba/services/session_resolver.rb +72 -0
  86. data/lib/soba/services/slack_notifier.rb +121 -0
  87. data/lib/soba/services/status_manager.rb +74 -0
  88. data/lib/soba/services/test_process_manager.rb +84 -0
  89. data/lib/soba/services/tmux_session_manager.rb +251 -0
  90. data/lib/soba/services/workflow_blocking_checker.rb +73 -0
  91. data/lib/soba/services/workflow_executor.rb +256 -0
  92. data/lib/soba/services/workflow_integrity_checker.rb +151 -0
  93. data/lib/soba/templates/claude_commands/implement.md +88 -0
  94. data/lib/soba/templates/claude_commands/plan.md +93 -0
  95. data/lib/soba/templates/claude_commands/review.md +91 -0
  96. data/lib/soba/templates/claude_commands/revise.md +76 -0
  97. data/lib/soba/version.rb +5 -0
  98. data/lib/soba.rb +44 -0
  99. data/lib/tasks/gem.rake +75 -0
  100. data/soba-cli.gemspec +59 -0
  101. 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