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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'json'
5
+ require_relative '../services/pid_manager'
6
+ require_relative '../services/status_manager'
7
+ require_relative '../services/process_info'
8
+
9
+ module Soba
10
+ module Commands
11
+ class Status
12
+ def execute(_global_options = {}, options = {}, _args = [])
13
+ # Allow test environment to override paths
14
+ pid_file = ENV['SOBA_TEST_PID_FILE'] || File.expand_path('~/.soba/soba.pid')
15
+ log_file = ENV['SOBA_TEST_LOG_FILE'] || File.expand_path('~/.soba/logs/daemon.log')
16
+ status_file = ENV['SOBA_TEST_STATUS_FILE'] || File.expand_path('~/.soba/status.json')
17
+
18
+ pid_manager = Soba::Services::PidManager.new(pid_file)
19
+ status_manager = Soba::Services::StatusManager.new(status_file)
20
+ pid = pid_manager.read
21
+
22
+ # JSON出力の場合
23
+ if options[:json]
24
+ output_json(pid_manager, status_manager, log_file, pid, options)
25
+ return 0
26
+ end
27
+
28
+ # 通常のテキスト出力
29
+ output_text(pid_manager, status_manager, log_file, pid, options)
30
+ end
31
+
32
+ private
33
+
34
+ def output_text(pid_manager, status_manager, log_file, pid, options)
35
+ puts "=" * 50
36
+ puts "Soba Daemon Status"
37
+ puts "=" * 50
38
+
39
+ status_data = status_manager.read
40
+
41
+ if pid_manager.running?
42
+ puts "Daemon Status: Running"
43
+ puts "PID: #{pid}"
44
+
45
+ # Try to get process start time and memory usage
46
+ begin
47
+ # Get file creation time as approximation
48
+ pid_file_path = File.expand_path('~/.soba/soba.pid')
49
+ if File.exist?(pid_file_path)
50
+ start_time = File.ctime(pid_file_path)
51
+ uptime = Time.now - start_time
52
+ puts "Started: #{start_time.strftime('%Y-%m-%d %H:%M:%S')}"
53
+ puts "Uptime: #{format_uptime(uptime)}"
54
+ end
55
+
56
+ # Get memory usage
57
+ process_info = Soba::Services::ProcessInfo.new(pid)
58
+ memory_mb = process_info.memory_usage_mb
59
+ memory_mb ||= status_data[:memory_mb] if status_data
60
+ puts "Memory Usage: #{memory_mb} MB" if memory_mb
61
+ rescue StandardError
62
+ # Ignore errors getting process info
63
+ end
64
+
65
+ # Display current Issue and last processed
66
+ if status_data
67
+ if status_data[:current_issue]
68
+ issue = status_data[:current_issue]
69
+ puts "\nCurrent Issue: ##{issue[:number]} (#{issue[:phase]})"
70
+ end
71
+
72
+ if status_data[:last_processed]
73
+ last = status_data[:last_processed]
74
+ puts "Last Processed: ##{last[:number]} (completed at #{last[:completed_at]})"
75
+ end
76
+ end
77
+ elsif pid
78
+ puts "Daemon Status: Not running"
79
+ puts "Stale PID file found (PID: #{pid})"
80
+ puts "Run 'soba start' to start the daemon"
81
+ return 1
82
+ else
83
+ puts "No daemon process is running"
84
+ puts "Run 'soba start' to start the daemon"
85
+ return 0
86
+ end
87
+
88
+ puts ""
89
+ puts "Recent Log Output:"
90
+ puts "-" * 50
91
+
92
+ if File.exist?(log_file)
93
+ if File.size(log_file) > 0
94
+ # Get specified number of log lines
95
+ num_lines = options[:log] || 10
96
+ log_lines = File.readlines(log_file).last(num_lines)
97
+ log_lines.each { |line| puts line.chomp }
98
+ else
99
+ puts "Log file is empty"
100
+ end
101
+ else
102
+ puts "No log file found at #{log_file}"
103
+ end
104
+
105
+ puts "=" * 50
106
+
107
+ 0
108
+ end
109
+
110
+ def output_json(pid_manager, status_manager, log_file, pid, options)
111
+ status_data = status_manager.read || {}
112
+ result = {}
113
+
114
+ if pid_manager.running?
115
+ pid_file_path = File.expand_path('~/.soba/soba.pid')
116
+ daemon_info = {
117
+ status: 'running',
118
+ pid: pid,
119
+ }
120
+
121
+ # Add start time and uptime
122
+ if File.exist?(pid_file_path)
123
+ start_time = File.ctime(pid_file_path)
124
+ uptime = Time.now - start_time
125
+ daemon_info[:started_at] = start_time.iso8601
126
+ daemon_info[:uptime_seconds] = uptime.to_i
127
+ end
128
+
129
+ # Add memory usage
130
+ process_info = Soba::Services::ProcessInfo.new(pid)
131
+ memory_mb = process_info.memory_usage_mb || status_data[:memory_mb]
132
+ daemon_info[:memory_mb] = memory_mb if memory_mb
133
+
134
+ result[:daemon] = daemon_info
135
+ else
136
+ result[:daemon] = { status: 'not_running' }
137
+ end
138
+
139
+ # Add current Issue info
140
+ if status_data[:current_issue]
141
+ result[:current_issue] = status_data[:current_issue]
142
+ end
143
+
144
+ # Add last processed info
145
+ if status_data[:last_processed]
146
+ result[:last_processed] = status_data[:last_processed]
147
+ end
148
+
149
+ # Add logs
150
+ if File.exist?(log_file) && File.size(log_file) > 0
151
+ num_lines = options[:log] || 10
152
+ log_lines = File.readlines(log_file).last(num_lines)
153
+ result[:logs] = log_lines.map(&:chomp)
154
+ else
155
+ result[:logs] = []
156
+ end
157
+
158
+ puts JSON.pretty_generate(result)
159
+ end
160
+
161
+ def format_uptime(seconds)
162
+ days = (seconds / 86400).to_i
163
+ hours = ((seconds % 86400) / 3600).to_i
164
+ minutes = ((seconds % 3600) / 60).to_i
165
+
166
+ parts = []
167
+ parts << "#{days}d" if days > 0
168
+ parts << "#{hours}h" if hours > 0
169
+ parts << "#{minutes}m" if minutes > 0 || parts.empty?
170
+
171
+ parts.join(' ')
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative '../services/pid_manager'
5
+ require_relative '../infrastructure/tmux_client'
6
+
7
+ module Soba
8
+ module Commands
9
+ class Stop
10
+ def execute(_global_options = {}, options = {}, _args = [])
11
+ # Allow test environment to override PID file path
12
+ pid_file = ENV['SOBA_TEST_PID_FILE'] || File.expand_path('~/.soba/soba.pid')
13
+ pid_manager = Soba::Services::PidManager.new(pid_file)
14
+
15
+ pid = pid_manager.read
16
+
17
+ unless pid
18
+ puts "No daemon process is running"
19
+ return 1
20
+ end
21
+
22
+ unless pid_manager.running?
23
+ Soba.logger.info "Daemon was not running (stale PID file cleaned up)"
24
+ pid_manager.delete
25
+ return 0
26
+ end
27
+
28
+ puts "Stopping daemon (PID: #{pid})..."
29
+
30
+ # Create PID-based stopping file for graceful shutdown
31
+ # This ensures each process has its own stopping file to avoid conflicts
32
+ stopping_file = File.expand_path("~/.soba/stopping.#{pid}")
33
+ FileUtils.mkdir_p(File.dirname(stopping_file))
34
+ FileUtils.touch(stopping_file)
35
+
36
+ begin
37
+ # Check if force option is specified
38
+ if options[:force]
39
+ # Force kill immediately
40
+ Soba.logger.warn "Forcefully terminating daemon (PID: #{pid})"
41
+ Process.kill('KILL', pid)
42
+ sleep 1
43
+ cleanup_tmux_sessions
44
+ pid_manager.delete
45
+ FileUtils.rm_f(stopping_file)
46
+ Soba.logger.warn "Daemon forcefully terminated"
47
+ return 0
48
+ end
49
+
50
+ # Send SIGTERM for graceful shutdown
51
+ Process.kill('TERM', pid)
52
+ Soba.logger.info "Sent SIGTERM signal, waiting for daemon to terminate"
53
+
54
+ # Use custom timeout if specified
55
+ timeout_value = options[:timeout] || 30
56
+
57
+ # Wait for process to terminate gracefully
58
+ if wait_for_termination(pid, timeout: timeout_value)
59
+ Soba.logger.info "Daemon stopped successfully"
60
+ cleanup_tmux_sessions
61
+ pid_manager.delete
62
+ FileUtils.rm_f(stopping_file)
63
+ 0
64
+ else
65
+ # Force kill if not terminated
66
+ Soba.logger.warn "Daemon did not stop gracefully, forcefully terminating"
67
+ Process.kill('KILL', pid)
68
+ sleep 1
69
+ cleanup_tmux_sessions
70
+ pid_manager.delete
71
+ FileUtils.rm_f(stopping_file)
72
+ Soba.logger.warn "Daemon forcefully terminated"
73
+ 0
74
+ end
75
+ rescue Errno::ESRCH
76
+ # Process doesn't exist
77
+ Soba.logger.info "Process not found (already terminated)"
78
+ pid_manager.delete
79
+ FileUtils.rm_f(stopping_file)
80
+ 0
81
+ rescue Errno::EPERM
82
+ # Permission denied
83
+ puts "Permission denied: unable to stop daemon (PID: #{pid})"
84
+ puts "You may need to run this command with appropriate permissions"
85
+ FileUtils.rm_f(stopping_file)
86
+ 1
87
+ rescue StandardError => e
88
+ Soba.logger.error "Error stopping daemon: #{e.message}"
89
+ FileUtils.rm_f(stopping_file)
90
+ 1
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def wait_for_termination(pid, timeout: 30)
97
+ deadline = Time.now + timeout
98
+
99
+ while Time.now < deadline
100
+ begin
101
+ # Check if process still exists
102
+ Process.kill(0, pid)
103
+ sleep 0.5
104
+ rescue Errno::ESRCH
105
+ # Process no longer exists
106
+ return true
107
+ end
108
+ end
109
+
110
+ false
111
+ end
112
+
113
+ def cleanup_tmux_sessions
114
+ tmux_client = Soba::Infrastructure::TmuxClient.new
115
+ repository = Soba::Configuration.config.github&.repository
116
+
117
+ if repository
118
+ # Kill only the current process's session
119
+ session_name = "soba-#{repository.gsub(/[\/._]/, '-')}-#{Process.pid}"
120
+ if tmux_client.session_exists?(session_name)
121
+ Soba.logger.info "Cleaning up tmux session"
122
+ if tmux_client.kill_session(session_name)
123
+ Soba.logger.info "Killed tmux session: #{session_name}"
124
+ else
125
+ Soba.logger.warn "Failed to kill tmux session: #{session_name}"
126
+ end
127
+ end
128
+ else
129
+ # If no repository configured, try to clean up sessions with current PID
130
+ sessions = tmux_client.list_soba_sessions.select { |s| s.end_with?("-#{Process.pid}") }
131
+ unless sessions.empty?
132
+ Soba.logger.info "Cleaning up tmux sessions"
133
+ sessions.each do |session|
134
+ if tmux_client.kill_session(session)
135
+ Soba.logger.info "Killed tmux session: #{session}"
136
+ else
137
+ Soba.logger.warn "Failed to kill tmux session: #{session}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ rescue StandardError => e
143
+ Soba.logger.warn "Failed to cleanup tmux sessions: #{e.message}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'configuration'
4
+
5
+ module Soba
6
+ module ConfigLoader
7
+ class << self
8
+ def load(path: nil)
9
+ Configuration.load!(path: path)
10
+ rescue ConfigurationError => e
11
+ handle_config_error(e)
12
+ end
13
+
14
+ def reload
15
+ Configuration.reset_config
16
+ load
17
+ end
18
+
19
+ def config
20
+ @config ||= load
21
+ end
22
+
23
+ private
24
+
25
+ def handle_config_error(error)
26
+ Soba.logger.error "Configuration Error: #{error.message}"
27
+ Soba.logger.error "Please check your .soba/config.yml file."
28
+ exit 1
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'dry-configurable'
5
+ require 'yaml'
6
+ require 'pathname'
7
+
8
+ module Soba
9
+ class Configuration
10
+ extend Dry::Configurable
11
+
12
+ setting :github do
13
+ setting :token, default: ENV.fetch('GITHUB_TOKEN', nil)
14
+ setting :repository
15
+ end
16
+
17
+ setting :workflow do
18
+ setting :interval, default: 20
19
+ setting :use_tmux, default: true
20
+ setting :auto_merge_enabled, default: true
21
+ setting :closed_issue_cleanup_enabled, default: true
22
+ setting :closed_issue_cleanup_interval, default: 300 # 5 minutes in seconds
23
+ setting :tmux_command_delay, default: 3 # delay in seconds before sending commands to tmux
24
+ end
25
+
26
+ setting :slack do
27
+ setting :webhook_url
28
+ setting :notifications_enabled, default: false
29
+ end
30
+
31
+ setting :git do
32
+ setting :worktree_base_path, default: '.git/soba/worktrees'
33
+ setting :setup_workspace, default: true
34
+ end
35
+
36
+ setting :phase do
37
+ setting :plan do
38
+ setting :command
39
+ setting :options, default: []
40
+ setting :parameter
41
+ end
42
+ setting :implement do
43
+ setting :command
44
+ setting :options, default: []
45
+ setting :parameter
46
+ end
47
+ setting :review do
48
+ setting :command
49
+ setting :options, default: []
50
+ setting :parameter
51
+ end
52
+ setting :revise do
53
+ setting :command
54
+ setting :options, default: []
55
+ setting :parameter
56
+ end
57
+ end
58
+
59
+ class << self
60
+ def reset_config
61
+ @config = nil
62
+ configure do |c|
63
+ c.github.token = ENV.fetch('GITHUB_TOKEN', nil)
64
+ c.github.repository = nil
65
+ c.workflow.interval = 20
66
+ c.workflow.use_tmux = true
67
+ c.workflow.auto_merge_enabled = true
68
+ c.workflow.closed_issue_cleanup_enabled = true
69
+ c.workflow.closed_issue_cleanup_interval = 300
70
+ c.workflow.tmux_command_delay = 3
71
+ c.slack.webhook_url = nil
72
+ c.slack.notifications_enabled = false
73
+ c.git.worktree_base_path = '.git/soba/worktrees'
74
+ c.git.setup_workspace = true
75
+ c.phase.plan.command = nil
76
+ c.phase.plan.options = []
77
+ c.phase.plan.parameter = nil
78
+ c.phase.implement.command = nil
79
+ c.phase.implement.options = []
80
+ c.phase.implement.parameter = nil
81
+ c.phase.review.command = nil
82
+ c.phase.review.options = []
83
+ c.phase.review.parameter = nil
84
+ c.phase.revise.command = nil
85
+ c.phase.revise.options = []
86
+ c.phase.revise.parameter = nil
87
+ end
88
+ end
89
+
90
+ def load!(path: nil)
91
+ config_path = find_config_file(path)
92
+
93
+ if config_path && File.exist?(config_path)
94
+ load_from_file(config_path)
95
+ else
96
+ create_default_config(config_path || default_config_path)
97
+ end
98
+
99
+ validate!
100
+ config
101
+ end
102
+
103
+ private
104
+
105
+ def find_config_file(path)
106
+ return Pathname.new(path) if path
107
+
108
+ # プロジェクトルートの.soba/config.ymlを探す
109
+ project_root = find_project_root
110
+ return nil unless project_root
111
+
112
+ project_root.join('.soba', 'config.yml')
113
+ end
114
+
115
+ def find_project_root
116
+ current = Pathname.pwd
117
+
118
+ until current.root?
119
+ return current if current.join('.git').exist?
120
+ current = current.parent
121
+ end
122
+
123
+ Pathname.pwd
124
+ end
125
+
126
+ def default_config_path
127
+ find_project_root.join('.soba', 'config.yml')
128
+ end
129
+
130
+ def load_from_file(path)
131
+ content = File.read(path)
132
+ # 環境変数を展開
133
+ expanded_content = content.gsub(/\$\{([^}]+)\}/) do |match|
134
+ var_name = Regexp.last_match(1)
135
+ ENV[var_name] || match
136
+ end
137
+ data = YAML.safe_load(expanded_content, permitted_classes: [Symbol])
138
+
139
+ reset_config
140
+ configure do |c|
141
+ if data['github']
142
+ c.github.token = data.dig('github', 'token') || ENV.fetch('GITHUB_TOKEN', nil)
143
+ c.github.repository = data.dig('github', 'repository')
144
+ end
145
+
146
+ if data['workflow']
147
+ c.workflow.interval = data.dig('workflow', 'interval') || 20
148
+ c.workflow.use_tmux = data.dig('workflow', 'use_tmux') != false # default true
149
+ c.workflow.auto_merge_enabled = data.dig('workflow', 'auto_merge_enabled') != false # default true
150
+ cleanup_enabled = data.dig('workflow', 'closed_issue_cleanup_enabled')
151
+ c.workflow.closed_issue_cleanup_enabled = cleanup_enabled != false # default true
152
+ c.workflow.closed_issue_cleanup_interval = data.dig('workflow', 'closed_issue_cleanup_interval') || 300
153
+ c.workflow.tmux_command_delay = data.dig('workflow', 'tmux_command_delay') || 3
154
+ end
155
+
156
+ if data['slack']
157
+ c.slack.webhook_url = data.dig('slack', 'webhook_url')
158
+ c.slack.notifications_enabled = data.dig('slack', 'notifications_enabled') || false
159
+ end
160
+
161
+ if data['git']
162
+ c.git.worktree_base_path = data.dig('git', 'worktree_base_path') || '.git/soba/worktrees'
163
+ c.git.setup_workspace = data.dig('git', 'setup_workspace') != false # default true
164
+ end
165
+
166
+ if data['phase']
167
+ if data['phase']['plan']
168
+ c.phase.plan.command = data.dig('phase', 'plan', 'command')
169
+ c.phase.plan.options = data.dig('phase', 'plan', 'options') || []
170
+ c.phase.plan.parameter = data.dig('phase', 'plan', 'parameter')
171
+ end
172
+ if data['phase']['implement']
173
+ c.phase.implement.command = data.dig('phase', 'implement', 'command')
174
+ c.phase.implement.options = data.dig('phase', 'implement', 'options') || []
175
+ c.phase.implement.parameter = data.dig('phase', 'implement', 'parameter')
176
+ end
177
+ if data['phase']['review']
178
+ c.phase.review.command = data.dig('phase', 'review', 'command')
179
+ c.phase.review.options = data.dig('phase', 'review', 'options') || []
180
+ c.phase.review.parameter = data.dig('phase', 'review', 'parameter')
181
+ end
182
+ if data['phase']['revise']
183
+ c.phase.revise.command = data.dig('phase', 'revise', 'command')
184
+ c.phase.revise.options = data.dig('phase', 'revise', 'options') || []
185
+ c.phase.revise.parameter = data.dig('phase', 'revise', 'parameter')
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ def create_default_config(path)
192
+ path.dirname.mkpath
193
+
194
+ default_content = <<~YAML
195
+ # soba CLI configuration
196
+ github:
197
+ # GitHub Personal Access Token
198
+ # Can use environment variable: ${GITHUB_TOKEN}
199
+ token: ${GITHUB_TOKEN}
200
+
201
+ # Target repository (format: owner/repo)
202
+ repository: # e.g., douhashi/soba
203
+
204
+ workflow:
205
+ # Issue polling interval in seconds
206
+ interval: 20
207
+ # Use tmux for Claude execution (default: true)
208
+ use_tmux: true
209
+ # Enable automatic merging of PRs with soba:lgtm label (default: true)
210
+ auto_merge_enabled: true
211
+ # Enable automatic cleanup of tmux windows for closed issues (default: true)
212
+ closed_issue_cleanup_enabled: true
213
+ # Cleanup interval in seconds (default: 300 = 5 minutes)
214
+ closed_issue_cleanup_interval: 300
215
+ # Delay in seconds before sending commands to new tmux panes/windows (default: 3)
216
+ tmux_command_delay: 3
217
+
218
+ slack:
219
+ # Slack webhook URL for sending notifications
220
+ # Can use environment variable: ${SLACK_WEBHOOK_URL}
221
+ webhook_url: ${SLACK_WEBHOOK_URL}
222
+ # Enable Slack notifications for phase starts (default: false)
223
+ notifications_enabled: false
224
+
225
+ git:
226
+ # Base path for git worktrees
227
+ worktree_base_path: .git/soba/worktrees
228
+ # Automatically setup workspace on phase start
229
+ setup_workspace: true
230
+
231
+ # Phase command configuration (optional)
232
+ # phase:
233
+ # plan:
234
+ # command: claude
235
+ # options:
236
+ # - --dangerously-skip-permissions
237
+ # parameter: '/osoba:plan {{issue-number}}'
238
+ # implement:
239
+ # command: claude
240
+ # options:
241
+ # - --dangerously-skip-permissions
242
+ # parameter: '/osoba:implement {{issue-number}}'
243
+ # review:
244
+ # command: claude
245
+ # options:
246
+ # - --dangerously-skip-permissions
247
+ # parameter: '/soba:review {{issue-number}}'
248
+ YAML
249
+
250
+ File.write(path, default_content)
251
+ puts "Created default configuration at: #{path}"
252
+ puts "Please edit the configuration file and set your GitHub repository."
253
+ end
254
+
255
+ def validate!
256
+ errors = []
257
+
258
+ errors << "GitHub token is not set" if config.github.token.blank?
259
+ errors << "GitHub repository is not set" if config.github.repository.blank?
260
+ errors << "Workflow interval must be positive" if config.workflow.interval <= 0
261
+
262
+ unless errors.empty?
263
+ raise ConfigurationError, "Configuration errors:\n #{errors.join("\n ")}"
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-container"
4
+ require "dry-auto_inject"
5
+
6
+ module Soba
7
+ class Container
8
+ extend Dry::Container::Mixin
9
+
10
+ namespace :github do
11
+ register(:client) do
12
+ require_relative "infrastructure/github_client"
13
+ Infrastructure::GitHubClient.new
14
+ end
15
+ end
16
+
17
+ namespace :tmux do
18
+ register(:client) do
19
+ require_relative "infrastructure/tmux_client"
20
+ Infrastructure::TmuxClient.new
21
+ end
22
+ end
23
+
24
+ namespace :services do
25
+ register(:issue_monitor) do
26
+ require_relative "services/issue_monitor"
27
+ Services::IssueMonitor.new
28
+ end
29
+
30
+ register(:issue_watcher) do
31
+ require_relative "services/issue_watcher"
32
+ Services::IssueWatcher.new(github_client: Container["github.client"])
33
+ end
34
+
35
+ register(:tmux_session_manager) do
36
+ require_relative "services/tmux_session_manager"
37
+ Services::TmuxSessionManager.new(tmux_client: Container["tmux.client"])
38
+ end
39
+
40
+ register(:workflow_executor) do
41
+ require_relative "workflow_executor"
42
+ Services::WorkflowExecutor.new(tmux_session_manager: Container["services.tmux_session_manager"])
43
+ end
44
+ end
45
+ end
46
+
47
+ Import = Dry::AutoInject(Container)
48
+ end