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,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../configuration'
|
4
|
+
require_relative '../services/tmux_session_manager'
|
5
|
+
require_relative '../infrastructure/tmux_client'
|
6
|
+
|
7
|
+
module Soba
|
8
|
+
module Commands
|
9
|
+
class Open
|
10
|
+
class SessionNotFoundError < StandardError; end
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@tmux_client = Infrastructure::TmuxClient.new
|
14
|
+
@tmux_session_manager = Services::TmuxSessionManager.new(config: nil, tmux_client: @tmux_client)
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute(issue_number, options = {})
|
18
|
+
validate_tmux_installation!
|
19
|
+
|
20
|
+
if options[:list]
|
21
|
+
list_issue_sessions
|
22
|
+
elsif issue_number
|
23
|
+
open_issue_session(issue_number)
|
24
|
+
else
|
25
|
+
open_repository_session
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def validate_tmux_installation!
|
32
|
+
unless @tmux_client.tmux_installed?
|
33
|
+
raise Infrastructure::TmuxNotInstalled, 'tmux is not installed. Please install tmux and try again'
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def open_repository_session
|
38
|
+
Configuration.load!
|
39
|
+
|
40
|
+
repository = Configuration.config.github.repository
|
41
|
+
|
42
|
+
unless repository
|
43
|
+
raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
|
44
|
+
end
|
45
|
+
|
46
|
+
# First, try standard session search (new format without PID)
|
47
|
+
result = @tmux_session_manager.find_repository_session
|
48
|
+
|
49
|
+
unless result[:success]
|
50
|
+
raise ArgumentError, result[:error]
|
51
|
+
end
|
52
|
+
|
53
|
+
if result[:exists]
|
54
|
+
session_name = result[:session_name]
|
55
|
+
puts "Attaching to repository session #{session_name}..."
|
56
|
+
@tmux_client.attach_to_session(session_name)
|
57
|
+
else
|
58
|
+
# Fallback to find repository session by PID (for backward compatibility)
|
59
|
+
pid_result = @tmux_session_manager.find_repository_session_by_pid(repository)
|
60
|
+
|
61
|
+
if pid_result[:exists]
|
62
|
+
session_name = pid_result[:session_name]
|
63
|
+
puts "Attaching to repository session #{session_name}... (legacy format)"
|
64
|
+
@tmux_client.attach_to_session(session_name)
|
65
|
+
else
|
66
|
+
raise SessionNotFoundError, <<~MESSAGE
|
67
|
+
Repository session not found.
|
68
|
+
|
69
|
+
A session will be created automatically when you start the workflow:
|
70
|
+
soba start
|
71
|
+
|
72
|
+
Or check active sessions:
|
73
|
+
soba open --list
|
74
|
+
MESSAGE
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def open_issue_session(issue_number)
|
80
|
+
Configuration.load!
|
81
|
+
repository = Configuration.config.github.repository
|
82
|
+
|
83
|
+
unless repository
|
84
|
+
raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
|
85
|
+
end
|
86
|
+
|
87
|
+
# Convert repository format (e.g., "user/repo" -> "user-repo")
|
88
|
+
repository_name = repository.gsub(/[\/._]/, '-')
|
89
|
+
window_id = @tmux_session_manager.find_issue_window(repository_name, issue_number)
|
90
|
+
|
91
|
+
if window_id
|
92
|
+
puts "Attaching to Issue ##{issue_number} session..."
|
93
|
+
@tmux_client.attach_to_window(window_id)
|
94
|
+
else
|
95
|
+
raise SessionNotFoundError, <<~MESSAGE
|
96
|
+
Issue ##{issue_number} session not found.
|
97
|
+
|
98
|
+
To start a session:
|
99
|
+
soba start #{issue_number}
|
100
|
+
|
101
|
+
To check active sessions:
|
102
|
+
soba open --list
|
103
|
+
MESSAGE
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def list_issue_sessions
|
108
|
+
Configuration.load!
|
109
|
+
repository = Configuration.config.github.repository
|
110
|
+
|
111
|
+
unless repository
|
112
|
+
raise ArgumentError, 'GitHub repository is not configured. Please run "soba init" first.'
|
113
|
+
end
|
114
|
+
|
115
|
+
# Convert repository format (e.g., "user/repo" -> "user-repo")
|
116
|
+
repository_name = repository.gsub(/[\/._]/, '-')
|
117
|
+
sessions = @tmux_session_manager.list_issue_windows(repository_name)
|
118
|
+
|
119
|
+
if sessions.empty?
|
120
|
+
puts 'No active Issue sessions'
|
121
|
+
puts
|
122
|
+
puts 'To start a session:'
|
123
|
+
puts ' soba start <issue-number>'
|
124
|
+
else
|
125
|
+
puts 'Active Issue sessions:'
|
126
|
+
puts
|
127
|
+
sessions.each do |session|
|
128
|
+
issue_number = extract_issue_number(session[:window])
|
129
|
+
title = session[:title] || '(fetching title...)'
|
130
|
+
puts " ##{issue_number.ljust(6)} #{title}"
|
131
|
+
end
|
132
|
+
puts
|
133
|
+
puts 'To open a session:'
|
134
|
+
puts ' soba open <issue-number>'
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def extract_issue_number(window_name)
|
139
|
+
match = window_name.match(/issue-(\d+)/)
|
140
|
+
match ? match[1] : window_name
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,442 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require_relative '../configuration'
|
5
|
+
require_relative '../infrastructure/github_client'
|
6
|
+
require_relative '../infrastructure/tmux_client'
|
7
|
+
require_relative '../services/issue_watcher'
|
8
|
+
require_relative '../services/issue_processor'
|
9
|
+
require_relative '../services/workflow_executor'
|
10
|
+
require_relative '../services/tmux_session_manager'
|
11
|
+
require_relative '../services/workflow_blocking_checker'
|
12
|
+
require_relative '../services/queueing_service'
|
13
|
+
require_relative '../services/auto_merge_service'
|
14
|
+
require_relative '../services/closed_issue_window_cleaner'
|
15
|
+
require_relative '../domain/phase_strategy'
|
16
|
+
require_relative '../services/pid_manager'
|
17
|
+
require_relative '../services/daemon_service'
|
18
|
+
require_relative '../services/status_manager'
|
19
|
+
require_relative '../services/process_info'
|
20
|
+
|
21
|
+
module Soba
|
22
|
+
module Commands
|
23
|
+
class Start
|
24
|
+
attr_reader :configuration, :issue_processor
|
25
|
+
|
26
|
+
def initialize(configuration: nil, issue_processor: nil)
|
27
|
+
@configuration = configuration
|
28
|
+
@issue_processor = issue_processor
|
29
|
+
end
|
30
|
+
|
31
|
+
def execute(global_options, options, args)
|
32
|
+
# Handle deprecated --foreground option
|
33
|
+
if options[:foreground]
|
34
|
+
puts "DEPRECATED: The --foreground option is now the default behavior."
|
35
|
+
puts "This option will be removed in a future version."
|
36
|
+
options.delete(:foreground)
|
37
|
+
end
|
38
|
+
|
39
|
+
if args.empty?
|
40
|
+
# ćÆć¼ćÆććć¼å®č”ć¢ć¼ćļ¼ę¢åć®workflow runć®åä½ļ¼
|
41
|
+
execute_workflow(global_options, options)
|
42
|
+
else
|
43
|
+
# åäøIssueå®č”ć¢ć¼ćļ¼ę¢åć®workflow execute_issueć®åä½ļ¼
|
44
|
+
# ę©ęå¼ę°ćć§ććÆļ¼čØå®čŖćæč¾¼ćæåļ¼
|
45
|
+
if args[0].blank? || args[0].strip.empty?
|
46
|
+
warn "Error: Issue number is required"
|
47
|
+
return 1
|
48
|
+
end
|
49
|
+
execute_issue(args, options)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def log_output(message, options, daemon_service = nil)
|
56
|
+
if options[:daemon]
|
57
|
+
daemon_service&.log(message)
|
58
|
+
else
|
59
|
+
puts message
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def execute_workflow(global_options, options)
|
64
|
+
# Daemon mode setup
|
65
|
+
if options[:daemon]
|
66
|
+
# Allow test environment to override PID file path
|
67
|
+
pid_file = ENV['SOBA_TEST_PID_FILE'] || File.expand_path('~/.soba/soba.pid')
|
68
|
+
log_file = ENV['SOBA_TEST_LOG_FILE'] || File.expand_path('~/.soba/logs/daemon.log')
|
69
|
+
|
70
|
+
pid_manager = Soba::Services::PidManager.new(pid_file)
|
71
|
+
daemon_service = Soba::Services::DaemonService.new(
|
72
|
+
pid_manager: pid_manager,
|
73
|
+
log_file: log_file
|
74
|
+
)
|
75
|
+
|
76
|
+
# Check if already running
|
77
|
+
if daemon_service.already_running?
|
78
|
+
pid = pid_manager.read
|
79
|
+
puts "Daemon is already running (PID: #{pid})"
|
80
|
+
puts "Use 'soba stop' to stop the daemon or 'soba status' to check status"
|
81
|
+
return 1
|
82
|
+
end
|
83
|
+
|
84
|
+
# Daemonize
|
85
|
+
puts "Starting daemon..."
|
86
|
+
daemon_service.daemonize!
|
87
|
+
|
88
|
+
# Log startup
|
89
|
+
daemon_service.log("Daemon started successfully (PID: #{Process.pid})")
|
90
|
+
|
91
|
+
# Setup signal handlers for daemon
|
92
|
+
daemon_service.setup_signal_handlers do
|
93
|
+
@running = false
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
Soba::Configuration.load!
|
98
|
+
|
99
|
+
config = Soba::Configuration.config
|
100
|
+
|
101
|
+
# Initialize status manager (allow test environment to override path)
|
102
|
+
status_file = ENV['SOBA_TEST_STATUS_FILE'] || File.expand_path('~/.soba/status.json')
|
103
|
+
status_manager = Soba::Services::StatusManager.new(status_file)
|
104
|
+
|
105
|
+
unless config&.github&.repository
|
106
|
+
message = "Error: GitHub repository is not configured\n" \
|
107
|
+
"Please run 'soba init' or set repository in .soba/config.yml"
|
108
|
+
if options[:foreground]
|
109
|
+
puts message
|
110
|
+
else
|
111
|
+
daemon_service.log(message) if defined?(daemon_service)
|
112
|
+
end
|
113
|
+
return 1
|
114
|
+
end
|
115
|
+
|
116
|
+
github_client = Soba::Infrastructure::GitHubClient.new
|
117
|
+
tmux_client = Soba::Infrastructure::TmuxClient.new
|
118
|
+
tmux_session_manager = Soba::Services::TmuxSessionManager.new(
|
119
|
+
tmux_client: tmux_client
|
120
|
+
)
|
121
|
+
|
122
|
+
# Create empty tmux session at startup
|
123
|
+
session_result = tmux_session_manager.find_or_create_repository_session
|
124
|
+
if session_result[:success]
|
125
|
+
if session_result[:created]
|
126
|
+
message = "Created tmux session: #{session_result[:session_name]}"
|
127
|
+
else
|
128
|
+
message = "Using existing tmux session: #{session_result[:session_name]}"
|
129
|
+
end
|
130
|
+
if options[:daemon]
|
131
|
+
daemon_service.log(message) if defined?(daemon_service)
|
132
|
+
else
|
133
|
+
puts message
|
134
|
+
end
|
135
|
+
end
|
136
|
+
workflow_executor = Soba::Services::WorkflowExecutor.new(
|
137
|
+
tmux_session_manager: tmux_session_manager
|
138
|
+
)
|
139
|
+
phase_strategy = Soba::Domain::PhaseStrategy.new
|
140
|
+
issue_processor = Soba::Services::IssueProcessor.new(
|
141
|
+
github_client: github_client,
|
142
|
+
workflow_executor: workflow_executor,
|
143
|
+
phase_strategy: phase_strategy,
|
144
|
+
config: Soba::Configuration
|
145
|
+
)
|
146
|
+
blocking_checker = Soba::Services::WorkflowBlockingChecker.new(
|
147
|
+
github_client: github_client
|
148
|
+
)
|
149
|
+
queueing_service = Soba::Services::QueueingService.new(
|
150
|
+
github_client: github_client,
|
151
|
+
blocking_checker: blocking_checker
|
152
|
+
)
|
153
|
+
auto_merge_service = Soba::Services::AutoMergeService.new
|
154
|
+
cleanup_logger = SemanticLogger["ClosedIssueWindowCleaner"]
|
155
|
+
cleaner_service = Soba::Services::ClosedIssueWindowCleaner.new(
|
156
|
+
github_client: github_client,
|
157
|
+
tmux_client: tmux_client,
|
158
|
+
logger: cleanup_logger
|
159
|
+
)
|
160
|
+
|
161
|
+
repository = Soba::Configuration.config.github.repository
|
162
|
+
interval = Soba::Configuration.config.workflow.interval || 10
|
163
|
+
|
164
|
+
issue_watcher = Soba::Services::IssueWatcher.new(
|
165
|
+
client: github_client,
|
166
|
+
repository: repository,
|
167
|
+
interval: interval
|
168
|
+
)
|
169
|
+
|
170
|
+
# Log or print based on mode
|
171
|
+
startup_message = [
|
172
|
+
"Starting workflow monitor for #{repository}",
|
173
|
+
"Polling interval: #{interval} seconds",
|
174
|
+
"Auto-merge enabled: #{Soba::Configuration.config.workflow.auto_merge_enabled}",
|
175
|
+
"Closed issue cleanup enabled: #{Soba::Configuration.config.workflow.closed_issue_cleanup_enabled}",
|
176
|
+
]
|
177
|
+
|
178
|
+
if options[:daemon]
|
179
|
+
startup_message.each { |msg| daemon_service.log(msg) if defined?(daemon_service) }
|
180
|
+
else
|
181
|
+
startup_message.each { |msg| puts msg }
|
182
|
+
puts "Press Ctrl+C to stop"
|
183
|
+
end
|
184
|
+
|
185
|
+
@running = true
|
186
|
+
unless options[:daemon]
|
187
|
+
Signal.trap('INT') { @running = false }
|
188
|
+
Signal.trap('TERM') { @running = false }
|
189
|
+
end
|
190
|
+
|
191
|
+
# Clean up old stopping files on startup (from crashed processes)
|
192
|
+
cleanup_old_stopping_files
|
193
|
+
|
194
|
+
while @running
|
195
|
+
# Check for graceful shutdown request using PID-based stopping file
|
196
|
+
stopping_file = File.expand_path("~/.soba/stopping.#{Process.pid}")
|
197
|
+
if File.exist?(stopping_file)
|
198
|
+
message = "Graceful shutdown requested, completing current workflow..."
|
199
|
+
log_output(message, options, daemon_service)
|
200
|
+
@running = false
|
201
|
+
FileUtils.rm_f(stopping_file)
|
202
|
+
next
|
203
|
+
end
|
204
|
+
|
205
|
+
begin
|
206
|
+
issues = issue_watcher.fetch_issues
|
207
|
+
|
208
|
+
# Check for todo issues that need queueing
|
209
|
+
todo_issues = issues.select do |issue|
|
210
|
+
labels = issue.labels.map { |l| l[:name] }
|
211
|
+
labels.include?('soba:todo')
|
212
|
+
end
|
213
|
+
|
214
|
+
# Queue todo issues if no active issues exist
|
215
|
+
if todo_issues.any? && !blocking_checker.blocking?(repository, issues: issues)
|
216
|
+
queued_issue = queueing_service.queue_next_issue(repository)
|
217
|
+
if queued_issue
|
218
|
+
puts "\nā
Queued Issue ##{queued_issue.number} for processing: #{queued_issue.title}"
|
219
|
+
# Refresh issues to include the new queued state
|
220
|
+
issues = issue_watcher.fetch_issues
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Filter issues that need processing (including queued issues)
|
225
|
+
processable_issues = issues.select do |issue|
|
226
|
+
# Extract label names from hash array - labels are already hashes
|
227
|
+
labels = issue.labels.map { |l| l[:name] }
|
228
|
+
phase = phase_strategy.determine_phase(labels)
|
229
|
+
# Process queued issues and other phases
|
230
|
+
!phase.nil? && phase != :plan # Don't process todo directly, wait for queueing
|
231
|
+
end
|
232
|
+
|
233
|
+
# Sort by issue number (youngest first)
|
234
|
+
processable_issues.sort_by!(&:number)
|
235
|
+
|
236
|
+
# Update memory usage periodically
|
237
|
+
if Process.pid
|
238
|
+
process_info = Soba::Services::ProcessInfo.new(Process.pid)
|
239
|
+
memory_mb = process_info.memory_usage_mb
|
240
|
+
status_manager.update_memory(memory_mb) if memory_mb
|
241
|
+
end
|
242
|
+
|
243
|
+
# Check for approved PRs that need auto-merge (if enabled)
|
244
|
+
if Soba::Configuration.config.workflow.auto_merge_enabled
|
245
|
+
merge_result = auto_merge_service.execute
|
246
|
+
if merge_result[:merged_count] > 0
|
247
|
+
puts "\nšÆ Auto-merged #{merge_result[:merged_count]} PR(s)"
|
248
|
+
merge_result[:details][:merged].each do |pr|
|
249
|
+
puts " ā
PR ##{pr[:number]}: #{pr[:title]}"
|
250
|
+
end
|
251
|
+
end
|
252
|
+
if merge_result[:failed_count] > 0
|
253
|
+
puts "\nā ļø Failed to merge #{merge_result[:failed_count]} PR(s)"
|
254
|
+
merge_result[:details][:failed].each do |pr|
|
255
|
+
puts " ā PR ##{pr[:number]}: #{pr[:reason]}"
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# Cleanup closed issue windows (if enabled and interval has passed)
|
261
|
+
if cleaner_service.should_clean?
|
262
|
+
Soba.logger.info "Running closed issue cleanup..."
|
263
|
+
active_sessions = tmux_client.list_soba_sessions
|
264
|
+
active_sessions.each do |session|
|
265
|
+
cleaner_service.clean(session)
|
266
|
+
end
|
267
|
+
Soba.logger.info "Closed issue cleanup completed for #{active_sessions.size} session(s)"
|
268
|
+
end
|
269
|
+
|
270
|
+
# Process the first issue if available
|
271
|
+
if processable_issues.any?
|
272
|
+
issue = processable_issues.first
|
273
|
+
|
274
|
+
# Additional safety check: ensure no duplicate active issues before processing
|
275
|
+
# This prevents race conditions when multiple workflow instances might be running
|
276
|
+
active_labels = %w(soba:queued soba:planning soba:doing soba:reviewing soba:revising)
|
277
|
+
intermediate_labels = %w(soba:review-requested soba:requires-changes)
|
278
|
+
|
279
|
+
active_issues = issues.select do |i|
|
280
|
+
i_labels = i.labels.map { |l| l[:name] }
|
281
|
+
(i_labels & (active_labels + intermediate_labels)).any?
|
282
|
+
end
|
283
|
+
|
284
|
+
if active_issues.size > 1
|
285
|
+
puts "\nā ļø Detected multiple active issues (#{active_issues.size}).\n" \
|
286
|
+
" Skipping processing to avoid conflicts."
|
287
|
+
active_issues.each do |ai|
|
288
|
+
ai_labels = ai.labels.map { |l| l[:name] }
|
289
|
+
active_label = (ai_labels & (active_labels + intermediate_labels)).first
|
290
|
+
puts " - Issue ##{ai.number}: #{active_label}"
|
291
|
+
end
|
292
|
+
puts " Please resolve this manually or wait for the next cycle."
|
293
|
+
sleep(interval) if @running
|
294
|
+
next
|
295
|
+
end
|
296
|
+
|
297
|
+
puts "\nš Processing Issue ##{issue.number}: #{issue.title}"
|
298
|
+
|
299
|
+
# Update status with current issue
|
300
|
+
labels = issue.labels.map { |l| l[:name] }
|
301
|
+
phase_label = labels.find { |l| l.start_with?('soba:') }
|
302
|
+
status_manager.update_current_issue(issue.number, phase_label) if phase_label
|
303
|
+
|
304
|
+
# Convert Domain::Issue to Hash for issue_processor
|
305
|
+
# Extract label names for issue_processor
|
306
|
+
issue_hash = {
|
307
|
+
number: issue.number,
|
308
|
+
title: issue.title,
|
309
|
+
labels: issue.labels.map { |l| l[:name] },
|
310
|
+
}
|
311
|
+
|
312
|
+
result = issue_processor.process(issue_hash)
|
313
|
+
|
314
|
+
# Mark as last processed when done
|
315
|
+
if result && result[:success]
|
316
|
+
status_manager.update_last_processed
|
317
|
+
end
|
318
|
+
|
319
|
+
if result[:success]
|
320
|
+
if result[:skipped]
|
321
|
+
puts " Skipped: #{result[:reason]}"
|
322
|
+
else
|
323
|
+
puts " Phase: #{result[:phase]}"
|
324
|
+
puts " Label updated: #{result[:label_updated]}"
|
325
|
+
if result[:workflow_skipped]
|
326
|
+
puts " Workflow skipped: #{result[:reason]}"
|
327
|
+
elsif result[:mode] == 'tmux'
|
328
|
+
# Display enhanced tmux information
|
329
|
+
if result[:tmux_info]
|
330
|
+
session_name = result[:tmux_info][:session] || result[:session_name]
|
331
|
+
puts " šŗ Session: #{session_name}"
|
332
|
+
puts " š” Monitor: soba monitor #{session_name}"
|
333
|
+
puts " š Log: ~/.soba/logs/#{session_name}.log"
|
334
|
+
else
|
335
|
+
# Fallback to legacy output for backward compatibility
|
336
|
+
puts " Tmux session started: #{result[:session_name]}" if result[:session_name]
|
337
|
+
puts " You can attach with: tmux attach -t #{result[:session_name]}" if result[:session_name]
|
338
|
+
end
|
339
|
+
elsif result[:output]
|
340
|
+
puts " Workflow output: #{result[:output].strip}"
|
341
|
+
end
|
342
|
+
end
|
343
|
+
else
|
344
|
+
puts " ā Failed: #{result[:error]}"
|
345
|
+
end
|
346
|
+
end
|
347
|
+
|
348
|
+
sleep(interval) if @running
|
349
|
+
rescue StandardError => e
|
350
|
+
Soba.logger.error "Workflow execution error: #{e.message}"
|
351
|
+
puts e.backtrace.first(5).join("\n") if ENV['DEBUG']
|
352
|
+
sleep(interval) if @running
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
puts "\nWorkflow monitoring stopped"
|
357
|
+
end
|
358
|
+
|
359
|
+
def execute_issue(args, options = {})
|
360
|
+
# Check if issue number is provided
|
361
|
+
if args.empty? || args[0].nil? || args[0].empty? || args[0].strip.empty?
|
362
|
+
warn "Error: Issue number is required"
|
363
|
+
return 1
|
364
|
+
end
|
365
|
+
|
366
|
+
# Initialize configuration and issue processor if needed
|
367
|
+
@configuration ||= Soba::Configuration.load!
|
368
|
+
@issue_processor ||= Soba::Services::IssueProcessor.new
|
369
|
+
|
370
|
+
issue_number = args[0]
|
371
|
+
|
372
|
+
# Determine tmux mode based on priority
|
373
|
+
use_tmux = determine_tmux_mode(options)
|
374
|
+
|
375
|
+
# Display execution mode
|
376
|
+
if use_tmux
|
377
|
+
puts "Running issue ##{issue_number} with tmux"
|
378
|
+
else
|
379
|
+
if options["no-tmux"]
|
380
|
+
puts "Running in direct mode (tmux disabled)"
|
381
|
+
elsif ENV["SOBA_NO_TMUX"]
|
382
|
+
puts "Running in direct mode (tmux disabled by environment variable)"
|
383
|
+
else
|
384
|
+
puts "Running in direct mode"
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
begin
|
389
|
+
# Process the issue
|
390
|
+
@issue_processor.run(issue_number, use_tmux: use_tmux)
|
391
|
+
0
|
392
|
+
rescue StandardError => e
|
393
|
+
warn "Error: #{e.message}"
|
394
|
+
1
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
def determine_tmux_mode(options)
|
399
|
+
# Priority: CLI option > Environment variable > Config file
|
400
|
+
|
401
|
+
# 1. CLI option (highest priority)
|
402
|
+
if options["no-tmux"]
|
403
|
+
return false
|
404
|
+
end
|
405
|
+
|
406
|
+
# 2. Environment variable
|
407
|
+
env_value = ENV["SOBA_NO_TMUX"]
|
408
|
+
if env_value
|
409
|
+
# true or 1 means disable tmux
|
410
|
+
return !(env_value == "true" || env_value == "1")
|
411
|
+
end
|
412
|
+
|
413
|
+
# 3. Config file (lowest priority)
|
414
|
+
config = @configuration.respond_to?(:config) ? @configuration.config : @configuration
|
415
|
+
config.workflow.use_tmux
|
416
|
+
end
|
417
|
+
|
418
|
+
def cleanup_old_stopping_files
|
419
|
+
# Clean up stopping files from processes that no longer exist
|
420
|
+
stopping_dir = File.expand_path('~/.soba')
|
421
|
+
return unless File.directory?(stopping_dir)
|
422
|
+
|
423
|
+
Dir.glob(File.join(stopping_dir, 'stopping.*')).each do |file|
|
424
|
+
# Extract PID from filename
|
425
|
+
if file =~ /stopping\.(\d+)$/
|
426
|
+
pid = Regexp.last_match(1).to_i
|
427
|
+
begin
|
428
|
+
# Check if process exists
|
429
|
+
Process.kill(0, pid)
|
430
|
+
rescue Errno::ESRCH
|
431
|
+
# Process doesn't exist, remove the file
|
432
|
+
FileUtils.rm_f(file)
|
433
|
+
puts "Cleaned up stale stopping file: #{File.basename(file)}"
|
434
|
+
rescue Errno::EPERM
|
435
|
+
# We don't have permission to check this process, keep the file
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
end
|
440
|
+
end
|
441
|
+
end
|
442
|
+
end
|