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,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
|