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,331 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require_relative 'errors'
|
5
|
+
|
6
|
+
module Soba
|
7
|
+
module Infrastructure
|
8
|
+
class TmuxClient
|
9
|
+
def create_session(session_name)
|
10
|
+
_stdout, _stderr, status = execute_tmux_command('new-session', '-d', '-s', session_name)
|
11
|
+
status.exitstatus == 0
|
12
|
+
rescue Errno::ENOENT
|
13
|
+
raise TmuxNotInstalled, 'tmux is not installed or not in PATH'
|
14
|
+
end
|
15
|
+
|
16
|
+
def kill_session(session_name)
|
17
|
+
_stdout, _stderr, status = execute_tmux_command('kill-session', '-t', session_name)
|
18
|
+
status.exitstatus == 0
|
19
|
+
rescue Errno::ENOENT
|
20
|
+
raise TmuxNotInstalled, 'tmux is not installed or not in PATH'
|
21
|
+
end
|
22
|
+
|
23
|
+
def session_exists?(session_name)
|
24
|
+
_stdout, _stderr, status = execute_tmux_command('has-session', '-t', session_name)
|
25
|
+
status.exitstatus == 0
|
26
|
+
rescue Errno::ENOENT
|
27
|
+
false
|
28
|
+
end
|
29
|
+
|
30
|
+
def list_sessions
|
31
|
+
stdout, _stderr, status = execute_tmux_command('list-sessions')
|
32
|
+
return [] unless status.exitstatus == 0
|
33
|
+
|
34
|
+
parse_session_list(stdout)
|
35
|
+
rescue Errno::ENOENT
|
36
|
+
[]
|
37
|
+
end
|
38
|
+
|
39
|
+
def send_keys(session_name, command)
|
40
|
+
_stdout, _stderr, status = execute_tmux_command('send-keys', '-t', session_name, command, 'Enter')
|
41
|
+
status.exitstatus == 0
|
42
|
+
rescue Errno::ENOENT
|
43
|
+
false
|
44
|
+
end
|
45
|
+
|
46
|
+
def capture_pane(session_name)
|
47
|
+
stdout, _stderr, status = execute_tmux_command('capture-pane', '-t', session_name, '-p')
|
48
|
+
return nil unless status.exitstatus == 0
|
49
|
+
|
50
|
+
stdout
|
51
|
+
rescue Errno::ENOENT
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
def create_window(session_name, window_name)
|
56
|
+
_stdout, _stderr, status = execute_tmux_command('new-window', '-t', session_name, '-n', window_name)
|
57
|
+
status.exitstatus == 0
|
58
|
+
rescue Errno::ENOENT
|
59
|
+
false
|
60
|
+
end
|
61
|
+
|
62
|
+
def switch_window(session_name, window_name)
|
63
|
+
_stdout, _stderr, status = execute_tmux_command('select-window', '-t', "#{session_name}:#{window_name}")
|
64
|
+
status.exitstatus == 0
|
65
|
+
rescue Errno::ENOENT
|
66
|
+
false
|
67
|
+
end
|
68
|
+
|
69
|
+
def list_windows(session_name)
|
70
|
+
stdout, _stderr, status = execute_tmux_command('list-windows', '-t', session_name)
|
71
|
+
return [] unless status.exitstatus == 0
|
72
|
+
|
73
|
+
parse_window_list(stdout)
|
74
|
+
rescue Errno::ENOENT
|
75
|
+
[]
|
76
|
+
end
|
77
|
+
|
78
|
+
def rename_window(session_name, old_name, new_name)
|
79
|
+
_stdout, _stderr, status = execute_tmux_command('rename-window', '-t', "#{session_name}:#{old_name}", new_name)
|
80
|
+
status.exitstatus == 0
|
81
|
+
rescue Errno::ENOENT
|
82
|
+
false
|
83
|
+
end
|
84
|
+
|
85
|
+
def split_pane(session_name, direction)
|
86
|
+
flag = direction == :horizontal ? '-h' : '-v'
|
87
|
+
_stdout, _stderr, status = execute_tmux_command('split-window', '-t', session_name, flag)
|
88
|
+
status.exitstatus == 0
|
89
|
+
rescue Errno::ENOENT
|
90
|
+
false
|
91
|
+
end
|
92
|
+
|
93
|
+
def select_pane(session_name, pane_index)
|
94
|
+
_stdout, _stderr, status = execute_tmux_command('select-pane', '-t', "#{session_name}.#{pane_index}")
|
95
|
+
status.exitstatus == 0
|
96
|
+
rescue Errno::ENOENT
|
97
|
+
false
|
98
|
+
end
|
99
|
+
|
100
|
+
def resize_pane(session_name, direction, size)
|
101
|
+
direction_flags = { up: '-U', down: '-D', left: '-L', right: '-R' }
|
102
|
+
flag = direction_flags[direction]
|
103
|
+
_stdout, _stderr, status = execute_tmux_command('resize-pane', '-t', session_name, flag, size.to_s)
|
104
|
+
status.exitstatus == 0
|
105
|
+
rescue Errno::ENOENT
|
106
|
+
false
|
107
|
+
end
|
108
|
+
|
109
|
+
def close_pane(session_name, pane_index)
|
110
|
+
_stdout, _stderr, status = execute_tmux_command('kill-pane', '-t', "#{session_name}.#{pane_index}")
|
111
|
+
status.exitstatus == 0
|
112
|
+
rescue Errno::ENOENT
|
113
|
+
false
|
114
|
+
end
|
115
|
+
|
116
|
+
def session_info(session_name)
|
117
|
+
format_string = '#{session_name}: #{session_windows} windows ' \
|
118
|
+
'(created #{session_created_string}) [#{session_width}x#{session_height}]'
|
119
|
+
stdout, _stderr, status = execute_tmux_command('list-sessions', '-F', format_string)
|
120
|
+
return nil unless status.exitstatus == 0
|
121
|
+
|
122
|
+
parse_session_info(stdout, session_name)
|
123
|
+
rescue Errno::ENOENT
|
124
|
+
nil
|
125
|
+
end
|
126
|
+
|
127
|
+
def active_session
|
128
|
+
stdout, _stderr, status = execute_tmux_command('display-message', '-p', '#{session_name}')
|
129
|
+
return nil unless status.exitstatus == 0
|
130
|
+
|
131
|
+
stdout.strip
|
132
|
+
rescue Errno::ENOENT
|
133
|
+
nil
|
134
|
+
end
|
135
|
+
|
136
|
+
def session_attached?(session_name)
|
137
|
+
stdout, _stderr, status = execute_tmux_command('list-sessions', '-F', '#{session_name}: #{session_attached}',
|
138
|
+
'-f', "#{session_name}==#{session_name}")
|
139
|
+
return false unless status.exitstatus == 0
|
140
|
+
|
141
|
+
parse_attached_status(stdout)
|
142
|
+
rescue Errno::ENOENT
|
143
|
+
false
|
144
|
+
end
|
145
|
+
|
146
|
+
def find_pane(session_name)
|
147
|
+
stdout, _stderr, status = execute_tmux_command('list-panes', '-t', session_name, '-F', '#{pane_id}')
|
148
|
+
return nil unless status.exitstatus == 0
|
149
|
+
|
150
|
+
# Return first pane ID
|
151
|
+
stdout.lines.first&.strip
|
152
|
+
rescue Errno::ENOENT
|
153
|
+
nil
|
154
|
+
end
|
155
|
+
|
156
|
+
def capture_pane_continuous(pane_id)
|
157
|
+
last_content = nil
|
158
|
+
|
159
|
+
loop do
|
160
|
+
stdout, _stderr, status = execute_tmux_command('capture-pane', '-t', pane_id, '-p', '-S', '-')
|
161
|
+
break unless status.exitstatus == 0
|
162
|
+
|
163
|
+
# Yield only new content
|
164
|
+
if last_content.nil?
|
165
|
+
# 初回は全体を返す
|
166
|
+
yield stdout unless stdout.empty?
|
167
|
+
last_content = stdout
|
168
|
+
elsif stdout != last_content && stdout.length > last_content.length
|
169
|
+
# コンテンツが増えた場合は差分のみを返す
|
170
|
+
new_lines = stdout[last_content.length..-1]
|
171
|
+
yield new_lines unless new_lines.empty?
|
172
|
+
last_content = stdout
|
173
|
+
elsif stdout != last_content
|
174
|
+
# コンテンツが変わったが短くなった場合(画面クリアなど)は全体を返す
|
175
|
+
yield stdout unless stdout.empty?
|
176
|
+
last_content = stdout
|
177
|
+
end
|
178
|
+
|
179
|
+
sleep 1
|
180
|
+
end
|
181
|
+
rescue Errno::ENOENT
|
182
|
+
nil
|
183
|
+
end
|
184
|
+
|
185
|
+
def list_soba_sessions
|
186
|
+
sessions = list_sessions
|
187
|
+
|
188
|
+
if ENV['SOBA_TEST_MODE'] == 'true'
|
189
|
+
# テストモードの場合は、soba-test-で始まるセッションのみを返す
|
190
|
+
sessions.select { |s| s.start_with?('soba-test-') }
|
191
|
+
else
|
192
|
+
# 通常モードの場合は、soba-で始まるがsoba-test-で始まらないセッションを返す
|
193
|
+
sessions.select { |s| s.start_with?('soba-') && !s.start_with?('soba-test-') }
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def window_exists?(session_name, window_name)
|
198
|
+
windows = list_windows(session_name)
|
199
|
+
windows.include?(window_name)
|
200
|
+
rescue Errno::ENOENT
|
201
|
+
false
|
202
|
+
end
|
203
|
+
|
204
|
+
def split_window(session_name:, window_name:, vertical: true)
|
205
|
+
flag = vertical ? '-v' : '-h'
|
206
|
+
target = "#{session_name}:#{window_name}"
|
207
|
+
command_args = ['split-window', '-t', target, flag, '-P', '-F', '#{pane_id}']
|
208
|
+
stdout, stderr, status = execute_tmux_command(*command_args)
|
209
|
+
|
210
|
+
if status.exitstatus == 0
|
211
|
+
stdout.strip
|
212
|
+
else
|
213
|
+
error_details = {
|
214
|
+
stderr: stderr,
|
215
|
+
command: ['tmux'] + command_args,
|
216
|
+
exit_status: status.exitstatus,
|
217
|
+
}
|
218
|
+
[nil, error_details]
|
219
|
+
end
|
220
|
+
rescue Errno::ENOENT
|
221
|
+
nil
|
222
|
+
end
|
223
|
+
|
224
|
+
def list_panes(session_name, window_name)
|
225
|
+
target = "#{session_name}:#{window_name}"
|
226
|
+
stdout, _stderr, status = execute_tmux_command(
|
227
|
+
'list-panes', '-t', target, '-F', '#{pane_id}:#{pane_start_time}'
|
228
|
+
)
|
229
|
+
return [] unless status.exitstatus == 0
|
230
|
+
|
231
|
+
stdout.lines.map do |line|
|
232
|
+
parts = line.strip.split(':')
|
233
|
+
{ id: parts[0], start_time: parts[1].to_i }
|
234
|
+
end
|
235
|
+
rescue Errno::ENOENT
|
236
|
+
[]
|
237
|
+
end
|
238
|
+
|
239
|
+
def kill_pane(pane_id)
|
240
|
+
_stdout, _stderr, status = execute_tmux_command('kill-pane', '-t', pane_id)
|
241
|
+
status.exitstatus == 0
|
242
|
+
rescue Errno::ENOENT
|
243
|
+
false
|
244
|
+
end
|
245
|
+
|
246
|
+
def select_layout(session_name, window_name, layout)
|
247
|
+
target = "#{session_name}:#{window_name}"
|
248
|
+
_stdout, _stderr, status = execute_tmux_command('select-layout', '-t', target, layout)
|
249
|
+
status.exitstatus == 0
|
250
|
+
rescue Errno::ENOENT
|
251
|
+
false
|
252
|
+
end
|
253
|
+
|
254
|
+
def kill_window(session_name, window_name)
|
255
|
+
target = "#{session_name}:#{window_name}"
|
256
|
+
_stdout, _stderr, status = execute_tmux_command('kill-window', '-t', target)
|
257
|
+
status.exitstatus == 0
|
258
|
+
rescue Errno::ENOENT
|
259
|
+
false
|
260
|
+
end
|
261
|
+
|
262
|
+
def tmux_installed?
|
263
|
+
_stdout, _stderr, _status = execute_tmux_command('list-sessions')
|
264
|
+
true
|
265
|
+
rescue Errno::ENOENT
|
266
|
+
false
|
267
|
+
end
|
268
|
+
|
269
|
+
def attach_to_window(window_id)
|
270
|
+
# Use system call to attach to tmux session
|
271
|
+
system("tmux", "attach-session", "-t", window_id)
|
272
|
+
rescue Errno::ENOENT
|
273
|
+
false
|
274
|
+
end
|
275
|
+
|
276
|
+
def attach_to_session(session_name)
|
277
|
+
# Use system call to attach to tmux session
|
278
|
+
system("tmux", "attach-session", "-t", session_name)
|
279
|
+
rescue Errno::ENOENT
|
280
|
+
false
|
281
|
+
end
|
282
|
+
|
283
|
+
private
|
284
|
+
|
285
|
+
def execute_tmux_command(*args)
|
286
|
+
Open3.capture3('tmux', *args)
|
287
|
+
end
|
288
|
+
|
289
|
+
def parse_session_list(output)
|
290
|
+
output.lines.map { |line| line.split(':').first }.compact
|
291
|
+
end
|
292
|
+
|
293
|
+
def parse_window_list(output)
|
294
|
+
output.lines.map do |line|
|
295
|
+
# Handle both active (*) and inactive (-) window markers
|
296
|
+
match = line.match(/^\d+:\s+(\S+?)[\*\-]?\s/)
|
297
|
+
match[1] if match
|
298
|
+
end.compact
|
299
|
+
end
|
300
|
+
|
301
|
+
def parse_session_info(output, session_name)
|
302
|
+
output.lines.each do |line|
|
303
|
+
if line.start_with?("#{session_name}:")
|
304
|
+
# Handle both single line and multi-line formats
|
305
|
+
combined_line = output.strip.gsub("\n", " ")
|
306
|
+
match = combined_line.match(/^(.+?):\s+(\d+)\s+windows?\s+\(created\s+(.+?)\)\s*\[(\d+)x(\d+)\]/)
|
307
|
+
if match
|
308
|
+
return {
|
309
|
+
name: match[1],
|
310
|
+
windows: match[2].to_i,
|
311
|
+
created_at: match[3],
|
312
|
+
size: [match[4].to_i, match[5].to_i],
|
313
|
+
}
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
nil
|
318
|
+
end
|
319
|
+
|
320
|
+
def parse_attached_status(output)
|
321
|
+
return false if output.empty?
|
322
|
+
|
323
|
+
line = output.lines.first.strip
|
324
|
+
match = line.match(/:\s+(\d+)$/)
|
325
|
+
return false unless match
|
326
|
+
|
327
|
+
match[1] == '1'
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soba
|
4
|
+
module Services
|
5
|
+
# ANSIエスケープシーケンスとコントロール文字の処理
|
6
|
+
class AnsiProcessor
|
7
|
+
def initialize(preserve_colors: false, strip_codes: true, convert_to_paint: false)
|
8
|
+
@preserve_colors = preserve_colors
|
9
|
+
@strip_codes = strip_codes
|
10
|
+
@convert_to_paint = convert_to_paint
|
11
|
+
end
|
12
|
+
|
13
|
+
# テキストを処理
|
14
|
+
def process(text)
|
15
|
+
return "" if text.blank?
|
16
|
+
|
17
|
+
processed = text.dup
|
18
|
+
|
19
|
+
if @strip_codes
|
20
|
+
processed = strip_ansi_codes(processed)
|
21
|
+
elsif @convert_to_paint
|
22
|
+
processed = convert_to_paint_format(processed)
|
23
|
+
return processed # Paint形式の場合はcontrol文字処理をスキップ
|
24
|
+
end
|
25
|
+
|
26
|
+
handle_control_chars(processed)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# ANSIエスケープシーケンスを削除
|
32
|
+
def strip_ansi_codes(text)
|
33
|
+
# CSI sequences (色、カーソル移動など)
|
34
|
+
text = text.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
35
|
+
|
36
|
+
# OSC sequences (ターミナルタイトルなど)
|
37
|
+
text = text.gsub(/\e\][^\a]*\a/, "")
|
38
|
+
|
39
|
+
# その他のエスケープシーケンス
|
40
|
+
text = text.gsub(/\e\[[\?!][0-9;]*[a-zA-Z]/, "")
|
41
|
+
|
42
|
+
text
|
43
|
+
end
|
44
|
+
|
45
|
+
# Paint gem形式に変換(実装例)
|
46
|
+
def convert_to_paint_format(text)
|
47
|
+
# 簡易的な実装例
|
48
|
+
# 実際にはPaint gemのAPIに合わせて実装
|
49
|
+
text.gsub(/\e\[31m(.*?)\e\[0m/, '[[\1]]')
|
50
|
+
end
|
51
|
+
|
52
|
+
# コントロール文字の処理
|
53
|
+
def handle_control_chars(text)
|
54
|
+
lines = text.split("\n")
|
55
|
+
|
56
|
+
lines.map! do |line|
|
57
|
+
# キャリッジリターンの処理
|
58
|
+
if line.include?("\r")
|
59
|
+
parts = line.split("\r")
|
60
|
+
# 最後の部分が現在の行
|
61
|
+
current = parts.last || ""
|
62
|
+
|
63
|
+
# 前の部分があれば、現在の行で上書き
|
64
|
+
if parts.size > 1 && parts[-2]
|
65
|
+
prev = parts[-2]
|
66
|
+
if current.length < prev.length
|
67
|
+
# 現在の行が短い場合、前の行の残りを追加
|
68
|
+
current += prev[current.length..-1].to_s
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
line = current
|
73
|
+
end
|
74
|
+
|
75
|
+
# バックスペースの処理
|
76
|
+
while line.include?("\b")
|
77
|
+
idx = line.index("\b")
|
78
|
+
if idx && idx > 0
|
79
|
+
line = line[0...idx - 1] + line[idx + 1..-1].to_s
|
80
|
+
else
|
81
|
+
line = line.sub("\b", "")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
line
|
86
|
+
end
|
87
|
+
|
88
|
+
lines.join("\n")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "semantic_logger"
|
4
|
+
require_relative "../infrastructure/github_client"
|
5
|
+
require_relative "../configuration"
|
6
|
+
|
7
|
+
module Soba
|
8
|
+
module Services
|
9
|
+
class AutoMergeService
|
10
|
+
include SemanticLogger::Loggable
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@github_client = Infrastructure::GitHubClient.new
|
14
|
+
@repository = Configuration.config.github.repository
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute
|
18
|
+
logger.info "Starting auto-merge process", repository: @repository
|
19
|
+
|
20
|
+
approved_prs = find_approved_prs
|
21
|
+
|
22
|
+
if approved_prs.empty?
|
23
|
+
logger.info "No PRs with soba:lgtm label found"
|
24
|
+
return {
|
25
|
+
merged_count: 0,
|
26
|
+
failed_count: 0,
|
27
|
+
details: { merged: [], failed: [] },
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
logger.info "Found approved PRs", count: approved_prs.size, pr_numbers: approved_prs.map { |pr| pr[:number] }
|
32
|
+
|
33
|
+
merged = []
|
34
|
+
failed = []
|
35
|
+
|
36
|
+
approved_prs.each do |pr|
|
37
|
+
pr_number = pr[:number]
|
38
|
+
logger.info "Processing PR", pr_number: pr_number, title: pr[:title]
|
39
|
+
|
40
|
+
begin
|
41
|
+
if check_mergeable(pr_number)
|
42
|
+
result = perform_merge(pr_number)
|
43
|
+
if result[:merged]
|
44
|
+
handle_post_merge(pr_number)
|
45
|
+
merged << { number: pr_number, title: pr[:title], sha: result[:sha] }
|
46
|
+
logger.info "PR merged successfully", pr_number: pr_number, sha: result[:sha]
|
47
|
+
else
|
48
|
+
failed << { number: pr_number, title: pr[:title], reason: "Merge returned false" }
|
49
|
+
logger.warn "PR merge returned false", pr_number: pr_number
|
50
|
+
end
|
51
|
+
else
|
52
|
+
failed << { number: pr_number, title: pr[:title], reason: "PR is not mergeable (conflicts or CI issues)" }
|
53
|
+
logger.warn "PR is not mergeable", pr_number: pr_number
|
54
|
+
end
|
55
|
+
rescue Infrastructure::MergeConflictError => e
|
56
|
+
failed << { number: pr_number, title: pr[:title], reason: e.message }
|
57
|
+
logger.error "Merge conflict error", pr_number: pr_number, error: e.message
|
58
|
+
rescue => e
|
59
|
+
failed << { number: pr_number, title: pr[:title], reason: e.message }
|
60
|
+
logger.error "Unexpected error during merge", pr_number: pr_number, error: e.message,
|
61
|
+
backtrace: e.backtrace.first(5)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
logger.info "Auto-merge process completed",
|
66
|
+
merged_count: merged.size,
|
67
|
+
failed_count: failed.size
|
68
|
+
|
69
|
+
{
|
70
|
+
merged_count: merged.size,
|
71
|
+
failed_count: failed.size,
|
72
|
+
details: {
|
73
|
+
merged: merged,
|
74
|
+
failed: failed,
|
75
|
+
},
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def find_approved_prs
|
82
|
+
logger.debug "Searching for PRs with soba:lgtm label"
|
83
|
+
@github_client.search_pull_requests(repository: @repository, labels: ["soba:lgtm"])
|
84
|
+
rescue => e
|
85
|
+
logger.error "Failed to find approved PRs", error: e.message
|
86
|
+
[]
|
87
|
+
end
|
88
|
+
|
89
|
+
def check_mergeable(pr_number)
|
90
|
+
logger.debug "Checking if PR is mergeable", pr_number: pr_number
|
91
|
+
|
92
|
+
pr = @github_client.get_pull_request(@repository, pr_number)
|
93
|
+
|
94
|
+
# Check both mergeable flag and mergeable_state
|
95
|
+
# mergeable_state can be: "clean", "dirty", "unknown", "blocked", "behind", "unstable", "has_hooks", "draft"
|
96
|
+
is_mergeable = pr[:mergeable] == true && pr[:mergeable_state] == "clean"
|
97
|
+
|
98
|
+
logger.debug "PR mergeable status",
|
99
|
+
pr_number: pr_number,
|
100
|
+
mergeable: pr[:mergeable],
|
101
|
+
mergeable_state: pr[:mergeable_state],
|
102
|
+
is_mergeable: is_mergeable
|
103
|
+
|
104
|
+
is_mergeable
|
105
|
+
rescue => e
|
106
|
+
logger.error "Failed to check mergeable status", pr_number: pr_number, error: e.message
|
107
|
+
false
|
108
|
+
end
|
109
|
+
|
110
|
+
def perform_merge(pr_number)
|
111
|
+
logger.info "Merging PR", pr_number: pr_number, merge_method: "squash"
|
112
|
+
|
113
|
+
@github_client.merge_pull_request(@repository, pr_number, merge_method: "squash")
|
114
|
+
end
|
115
|
+
|
116
|
+
def handle_post_merge(pr_number)
|
117
|
+
logger.debug "Handling post-merge actions", pr_number: pr_number
|
118
|
+
|
119
|
+
# Extract issue number from PR body
|
120
|
+
issue_number = @github_client.get_pr_issue_number(@repository, pr_number)
|
121
|
+
|
122
|
+
if issue_number
|
123
|
+
logger.info "Closing related issue", pr_number: pr_number, issue_number: issue_number
|
124
|
+
@github_client.close_issue_with_label(@repository, issue_number, label: "soba:merged")
|
125
|
+
else
|
126
|
+
logger.warn "No related issue found in PR body", pr_number: pr_number
|
127
|
+
end
|
128
|
+
rescue => e
|
129
|
+
logger.error "Failed to handle post-merge actions", pr_number: pr_number, error: e.message
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soba
|
4
|
+
module Services
|
5
|
+
class ClosedIssueWindowCleaner
|
6
|
+
attr_reader :github_client, :tmux_client, :logger
|
7
|
+
|
8
|
+
def initialize(github_client:, tmux_client:, logger:)
|
9
|
+
@github_client = github_client
|
10
|
+
@tmux_client = tmux_client
|
11
|
+
@logger = logger
|
12
|
+
@last_cleanup_time = nil
|
13
|
+
@repository = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def clean(session_name)
|
17
|
+
logger.debug("Cleaning up windows for closed issues in session: #{session_name}")
|
18
|
+
|
19
|
+
begin
|
20
|
+
closed_issues = fetch_closed_issues
|
21
|
+
if closed_issues.empty?
|
22
|
+
logger.debug('No closed issues found')
|
23
|
+
return
|
24
|
+
end
|
25
|
+
|
26
|
+
logger.debug("Found #{closed_issues.size} closed issues")
|
27
|
+
|
28
|
+
windows = list_tmux_windows(session_name)
|
29
|
+
return if windows.nil?
|
30
|
+
|
31
|
+
removed_count = 0
|
32
|
+
closed_issues.each do |issue|
|
33
|
+
window_name = "issue-#{issue.number}"
|
34
|
+
if windows.include?(window_name)
|
35
|
+
if remove_window(session_name, window_name, issue)
|
36
|
+
removed_count += 1
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
logger.debug("Cleanup completed for #{session_name}: removed #{removed_count} windows")
|
42
|
+
rescue StandardError => e
|
43
|
+
logger.error("Unexpected error during cleanup: #{e.message}")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def should_clean?
|
48
|
+
return false unless config.workflow.closed_issue_cleanup_enabled
|
49
|
+
|
50
|
+
if @last_cleanup_time.nil?
|
51
|
+
@last_cleanup_time = Time.now
|
52
|
+
return true
|
53
|
+
end
|
54
|
+
|
55
|
+
time_since_last = Time.now - @last_cleanup_time
|
56
|
+
if time_since_last >= config.workflow.closed_issue_cleanup_interval
|
57
|
+
@last_cleanup_time = Time.now
|
58
|
+
return true
|
59
|
+
end
|
60
|
+
|
61
|
+
false
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def config
|
67
|
+
@config ||= Soba::Configuration.config
|
68
|
+
end
|
69
|
+
|
70
|
+
def fetch_closed_issues
|
71
|
+
repository = config.github.repository || ENV['GITHUB_REPOSITORY']
|
72
|
+
github_client.fetch_closed_issues(repository)
|
73
|
+
rescue StandardError => e
|
74
|
+
logger.error("Failed to fetch closed issues: #{e.message}")
|
75
|
+
[]
|
76
|
+
end
|
77
|
+
|
78
|
+
def list_tmux_windows(session_name)
|
79
|
+
tmux_client.list_windows(session_name)
|
80
|
+
rescue StandardError => e
|
81
|
+
logger.error("Failed to list tmux windows: #{e.message}")
|
82
|
+
nil
|
83
|
+
end
|
84
|
+
|
85
|
+
def remove_window(session_name, window_name, issue)
|
86
|
+
if tmux_client.kill_window(session_name, window_name)
|
87
|
+
logger.info("Removed window: #{window_name} (Issue ##{issue.number}: #{issue.title})")
|
88
|
+
true
|
89
|
+
else
|
90
|
+
logger.warn("Failed to remove window: #{window_name}")
|
91
|
+
false
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|