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