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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ module Soba
7
+ module Services
8
+ class DaemonService
9
+ attr_reader :pid_manager, :log_file
10
+
11
+ def initialize(pid_manager:, log_file: nil)
12
+ @pid_manager = pid_manager
13
+ @log_file = log_file || File.expand_path('~/.soba/logs/daemon.log')
14
+ end
15
+
16
+ def already_running?
17
+ if pid_manager.running?
18
+ true
19
+ else
20
+ # Clean up stale PID file if exists
21
+ pid_manager.cleanup_if_stale
22
+ false
23
+ end
24
+ end
25
+
26
+ def daemonize!
27
+ # Fork and detach from terminal
28
+ Process.daemon(true, false)
29
+
30
+ # Write PID file
31
+ pid_manager.write
32
+
33
+ # Ensure log directory exists
34
+ ensure_log_directory
35
+
36
+ # Redirect stdout and stderr to log file
37
+ redirect_output_to_log
38
+ end
39
+
40
+ def setup_signal_handlers(&cleanup_block)
41
+ %w(TERM INT).each do |signal|
42
+ Signal.trap(signal) do
43
+ log "Received SIG#{signal}, shutting down gracefully..."
44
+ cleanup_block&.call
45
+ cleanup
46
+ exit(0)
47
+ end
48
+ end
49
+ end
50
+
51
+ def cleanup
52
+ log 'Cleaning up daemon...'
53
+ pid_manager.delete
54
+ end
55
+
56
+ def log(message)
57
+ ensure_log_directory
58
+ timestamp = Time.now.strftime('[%Y-%m-%d %H:%M:%S]')
59
+ File.open(log_file, 'a') do |f|
60
+ f.puts "#{timestamp} #{message}"
61
+ f.flush
62
+ end
63
+ rescue StandardError => e
64
+ warn "Failed to write to log: #{e.message}"
65
+ end
66
+
67
+ def ensure_log_directory
68
+ dir = File.dirname(log_file)
69
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
70
+ end
71
+
72
+ private
73
+
74
+ def redirect_output_to_log
75
+ log_io = File.open(log_file, 'a')
76
+ log_io.sync = true
77
+
78
+ $stdout.reopen(log_io)
79
+ $stderr.reopen(log_io)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require_relative '../configuration'
6
+
7
+ module Soba
8
+ module Services
9
+ class GitWorkspaceManager
10
+ class GitOperationError < StandardError; end
11
+
12
+ def initialize(configuration: nil)
13
+ @configuration = configuration || Soba::Configuration
14
+ end
15
+
16
+ def setup_workspace(issue_number)
17
+ worktree_path = worktree_path(issue_number)
18
+ branch_name = branch_name(issue_number)
19
+
20
+ # 既存のworktreeが存在する場合はスキップ
21
+ if Dir.exist?(worktree_path)
22
+ puts "Worktree already exists at #{worktree_path}, skipping setup"
23
+ return true
24
+ end
25
+
26
+ # worktreeディレクトリを作成
27
+ FileUtils.mkdir_p(@configuration.config.git.worktree_base_path)
28
+
29
+ # worktreeを作成
30
+ create_worktree(worktree_path, branch_name)
31
+
32
+ true
33
+ end
34
+
35
+ def cleanup_workspace(issue_number)
36
+ worktree_path = worktree_path(issue_number)
37
+
38
+ # worktreeが存在しない場合はスキップ
39
+ unless Dir.exist?(worktree_path)
40
+ puts "Worktree does not exist at #{worktree_path}, skipping cleanup"
41
+ return true
42
+ end
43
+
44
+ # worktreeを削除
45
+ remove_worktree(worktree_path)
46
+
47
+ true
48
+ end
49
+
50
+ def get_worktree_path(issue_number)
51
+ path = worktree_path(issue_number)
52
+ Dir.exist?(path) ? path : nil
53
+ end
54
+
55
+ def update_main_branch
56
+ # git fetch origin
57
+ _, stderr, status = Open3.capture3('git fetch origin')
58
+ unless status.success?
59
+ raise GitOperationError, "Failed to fetch from origin: #{stderr}"
60
+ end
61
+
62
+ # git checkout main
63
+ _, stderr, status = Open3.capture3('git checkout main')
64
+ unless status.success?
65
+ raise GitOperationError, "Failed to checkout main branch: #{stderr}"
66
+ end
67
+
68
+ # git pull origin main
69
+ _, stderr, status = Open3.capture3('git pull origin main')
70
+ unless status.success?
71
+ raise GitOperationError, "Failed to pull latest changes from main: #{stderr}"
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def worktree_path(issue_number)
78
+ "#{@configuration.config.git.worktree_base_path}/issue-#{issue_number}"
79
+ end
80
+
81
+ def branch_name(issue_number)
82
+ "soba/#{issue_number}"
83
+ end
84
+
85
+ def create_worktree(worktree_path, branch_name)
86
+ command = "git worktree add -b #{branch_name} #{worktree_path} origin/main"
87
+ _, stderr, status = Open3.capture3(command)
88
+ unless status.success?
89
+ raise GitOperationError, "Failed to create worktree: #{stderr}"
90
+ end
91
+ end
92
+
93
+ def remove_worktree(worktree_path)
94
+ command = "git worktree remove #{worktree_path} --force"
95
+ _, stderr, status = Open3.capture3(command)
96
+ unless status.success?
97
+ raise GitOperationError, "Failed to remove worktree: #{stderr}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soba
4
+ module Services
5
+ class IssueMonitor
6
+ def initialize(github_client: nil)
7
+ @github_client = github_client || Infrastructure::GitHubClient.new
8
+ end
9
+
10
+ def monitor(repository:, interval: 60)
11
+ Soba.logger.info("Starting issue monitor for #{repository}")
12
+
13
+ loop do
14
+ check_issues(repository)
15
+ sleep(interval)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def check_issues(repository)
22
+ issues = @github_client.issues(repository)
23
+ Soba.logger.debug("Found #{issues.count} open issues")
24
+ rescue => e
25
+ Soba.logger.error("Failed to fetch issues: #{e.message}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require_relative '../configuration'
5
+ require_relative '../infrastructure/github_client'
6
+ require_relative '../infrastructure/tmux_client'
7
+ require_relative '../domain/phase_strategy'
8
+ require_relative 'workflow_executor'
9
+ require_relative 'tmux_session_manager'
10
+ require_relative 'git_workspace_manager'
11
+
12
+ module Soba
13
+ module Services
14
+ class IssueProcessingError < StandardError; end
15
+
16
+ class IssueProcessor
17
+ attr_reader :github_client, :workflow_executor, :phase_strategy, :config
18
+
19
+ def initialize(github_client: nil, workflow_executor: nil, phase_strategy: nil, config: nil)
20
+ @github_client = github_client || Infrastructure::GitHubClient.new
21
+ @config = config || Configuration
22
+ @workflow_executor = workflow_executor || WorkflowExecutor.new(
23
+ tmux_session_manager: TmuxSessionManager.new(
24
+ tmux_client: Infrastructure::TmuxClient.new
25
+ ),
26
+ git_workspace_manager: GitWorkspaceManager.new(configuration: @config)
27
+ )
28
+ @phase_strategy = phase_strategy || Domain::PhaseStrategy.new
29
+ end
30
+
31
+ def run(issue_number, use_tmux: true)
32
+ # Fetch issue details from GitHub
33
+ repository = get_repository_from_config
34
+ issue = @github_client.issue(repository, issue_number)
35
+
36
+ # Convert issue to expected format
37
+ issue_hash = {
38
+ number: issue.number,
39
+ title: issue.title,
40
+ labels: issue.labels.map { |l| l.name || l[:name] },
41
+ }
42
+
43
+ # Process with the specified tmux mode
44
+ original_use_tmux = @config.config.workflow.use_tmux
45
+ begin
46
+ # Temporarily override the config value
47
+ @config.config.workflow.use_tmux = use_tmux
48
+ process(issue_hash)
49
+ ensure
50
+ # Restore original config value
51
+ @config.config.workflow.use_tmux = original_use_tmux
52
+ end
53
+ end
54
+
55
+ def process(issue)
56
+ phase = phase_strategy.determine_phase(issue[:labels])
57
+
58
+ return skipped_result('No phase determined for issue') unless phase
59
+
60
+ current_label = current_label_for_phase(phase)
61
+ next_label = phase_strategy.next_label(phase)
62
+
63
+ begin
64
+ repository = get_repository_from_config
65
+ github_client.update_issue_labels(
66
+ repository,
67
+ issue[:number],
68
+ from: current_label,
69
+ to: next_label
70
+ )
71
+ rescue StandardError => e
72
+ raise IssueProcessingError, "Failed to update labels: #{e.message}"
73
+ end
74
+
75
+ phase_config = get_phase_config(phase)
76
+
77
+ if phase_config&.command
78
+ puts "Processing phase: #{phase} with command: #{phase_config.command}"
79
+ puts " Phase name: #{phase_config.name || 'not set'}"
80
+
81
+ actual_config = config.respond_to?(:config) ? config.config : config
82
+ use_tmux = actual_config.workflow.use_tmux
83
+ setup_workspace = actual_config.git.setup_workspace
84
+
85
+ execution_result = workflow_executor.execute(
86
+ phase: phase_config,
87
+ issue_number: issue[:number],
88
+ use_tmux: use_tmux,
89
+ setup_workspace: setup_workspace,
90
+ issue_title: issue[:title],
91
+ phase_name: phase.to_s
92
+ )
93
+
94
+ result = {
95
+ success: execution_result[:success],
96
+ phase: phase,
97
+ issue_number: issue[:number],
98
+ label_updated: true,
99
+ output: execution_result[:output],
100
+ error: execution_result[:error],
101
+ }
102
+
103
+ # Add tmux-specific fields if present
104
+ result[:mode] = execution_result[:mode] if execution_result[:mode]
105
+ result[:session_name] = execution_result[:session_name] if execution_result[:session_name]
106
+ result[:window_name] = execution_result[:window_name] if execution_result[:window_name]
107
+ result[:pane_id] = execution_result[:pane_id] if execution_result[:pane_id]
108
+ result[:tmux_info] = execution_result[:tmux_info] if execution_result[:tmux_info]
109
+
110
+ result
111
+ else
112
+ {
113
+ success: true,
114
+ phase: phase,
115
+ issue_number: issue[:number],
116
+ label_updated: true,
117
+ workflow_skipped: true,
118
+ reason: 'Phase configuration not defined',
119
+ }
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def get_repository_from_config
126
+ actual_config = @config.respond_to?(:config) ? @config.config : @config
127
+ actual_config.github.repository
128
+ end
129
+
130
+ def skipped_result(reason)
131
+ {
132
+ success: true,
133
+ skipped: true,
134
+ reason: reason,
135
+ }
136
+ end
137
+
138
+ def current_label_for_phase(phase)
139
+ phase_strategy.current_label_for_phase(phase)
140
+ end
141
+
142
+ def get_phase_config(phase)
143
+ case phase
144
+ when :plan, :queued_to_planning
145
+ # config is the Configuration module, get the actual config object
146
+ actual_config = config.respond_to?(:config) ? config.config : config
147
+ plan_config = actual_config.phase.plan
148
+
149
+ # Access values through @_values instead of @values
150
+ values = plan_config.instance_variable_get(:@_values)
151
+
152
+ if values
153
+ OpenStruct.new(
154
+ name: 'plan',
155
+ command: values[:command],
156
+ options: values[:options],
157
+ parameter: values[:parameter]
158
+ )
159
+ else
160
+ nil
161
+ end
162
+ when :implement
163
+ actual_config = config.respond_to?(:config) ? config.config : config
164
+ impl_config = actual_config.phase.implement
165
+ values = impl_config.instance_variable_get(:@_values)
166
+
167
+ if values
168
+ OpenStruct.new(
169
+ name: 'implement',
170
+ command: values[:command],
171
+ options: values[:options],
172
+ parameter: values[:parameter]
173
+ )
174
+ else
175
+ nil
176
+ end
177
+ when :review
178
+ actual_config = config.respond_to?(:config) ? config.config : config
179
+ review_config = actual_config.phase.review
180
+ values = review_config.instance_variable_get(:@_values)
181
+
182
+ if values
183
+ OpenStruct.new(
184
+ name: 'review',
185
+ command: values[:command],
186
+ options: values[:options],
187
+ parameter: values[:parameter]
188
+ )
189
+ else
190
+ nil
191
+ end
192
+ when :revise
193
+ actual_config = config.respond_to?(:config) ? config.config : config
194
+ revise_config = actual_config.phase.revise
195
+ values = revise_config.instance_variable_get(:@_values)
196
+
197
+ if values
198
+ OpenStruct.new(
199
+ name: 'revise',
200
+ command: values[:command],
201
+ options: values[:options],
202
+ parameter: values[:parameter]
203
+ )
204
+ else
205
+ nil
206
+ end
207
+ else
208
+ nil
209
+ end
210
+ rescue StandardError
211
+ nil
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent-ruby"
4
+ require "time"
5
+
6
+ module Soba
7
+ module Services
8
+ class IssueWatcher
9
+ include SemanticLogger::Loggable
10
+
11
+ MIN_INTERVAL = 10
12
+
13
+ def initialize(client: nil, repository: nil, interval: nil)
14
+ @github_client = client || Infrastructure::GitHubClient.new
15
+ @repository = repository
16
+ @interval = interval
17
+ @running = Concurrent::AtomicBoolean.new(false)
18
+ @mutex = Mutex.new
19
+ @signal_received = false
20
+ end
21
+
22
+ def start(repository:, interval: 20)
23
+ validate_interval!(interval)
24
+
25
+ logger.info "Starting issue watcher", repository: repository, interval: interval
26
+ @running.make_true
27
+ @repository = repository
28
+ @interval = interval
29
+
30
+ setup_signal_handlers
31
+ display_header
32
+
33
+ execution_count = run_monitoring_loop
34
+
35
+ # Show graceful shutdown message
36
+ if @signal_received
37
+ puts "\n✅ Issue watcher stopped gracefully (#{execution_count} executions)"
38
+ else
39
+ puts "\n✅ Issue watcher stopped successfully (#{execution_count} executions)"
40
+ logger.info "Issue watcher stopped", executions: execution_count
41
+ end
42
+ ensure
43
+ @running.make_false
44
+ end
45
+
46
+ def stop
47
+ @running.make_false
48
+ end
49
+
50
+ def running?
51
+ @running.value
52
+ end
53
+
54
+ def fetch_issues(state: "open")
55
+ @github_client.issues(@repository, state: state)
56
+ end
57
+
58
+ private
59
+
60
+ def validate_interval!(interval)
61
+ if interval < MIN_INTERVAL
62
+ raise ArgumentError, "Interval must be at least #{MIN_INTERVAL} seconds to avoid rate limiting"
63
+ end
64
+ end
65
+
66
+ def setup_signal_handlers
67
+ %w(INT TERM).each do |signal|
68
+ Signal.trap(signal) do
69
+ @signal_received = true
70
+ puts "\n\n🛑 Received #{signal} signal, shutting down gracefully..."
71
+ @running.make_false
72
+ end
73
+ end
74
+ end
75
+
76
+ def run_monitoring_loop
77
+ execution_count = 0
78
+
79
+ while running?
80
+ @mutex.synchronize do
81
+ fetch_and_display_issues
82
+ execution_count += 1
83
+ end
84
+
85
+ break unless running?
86
+ sleep(@interval)
87
+ end
88
+
89
+ execution_count
90
+ rescue => e
91
+ # Skip logging if interrupted by signal
92
+ unless @signal_received
93
+ logger.error "Unexpected error in monitoring loop", error: e.message
94
+ raise
95
+ end
96
+ execution_count
97
+ end
98
+
99
+ def fetch_and_display_issues
100
+ issues = @github_client.issues(@repository, state: "open")
101
+
102
+ display_issues(issues)
103
+ log_execution_summary(issues)
104
+ rescue Soba::Infrastructure::NetworkError => e
105
+ logger.error "Failed to fetch issues", error: e.message, repository: @repository
106
+ puts "\n⚠️ Network error: #{e.message}"
107
+ rescue Soba::Infrastructure::RateLimitExceeded => e
108
+ logger.warn "Rate limit exceeded", error: e.message
109
+ puts "\n⚠️ Rate limit exceeded. Waiting before retry..."
110
+ handle_rate_limit
111
+ rescue => e
112
+ logger.error "Unexpected error fetching issues", error: e.message, class: e.class.name
113
+ puts "\n❌ Error: #{e.message}"
114
+ end
115
+
116
+ def display_header
117
+ puts "\n" + "=" * 80
118
+ puts "📋 Issue Watcher Started"
119
+ puts "Repository: #{@repository}"
120
+ puts "Interval: #{@interval} seconds"
121
+ puts "Press Ctrl+C to stop"
122
+ puts "=" * 80
123
+ puts
124
+ end
125
+
126
+ def display_issues(issues)
127
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
128
+ puts "\n[#{timestamp}] Found #{issues.count} open issues"
129
+
130
+ if issues.empty?
131
+ puts " No open issues found."
132
+ return
133
+ end
134
+
135
+ puts "\n %-6s | %-50s | %-20s | %s" % ["#", "Title", "Labels", "Updated"]
136
+ puts " #{"-" * 90}"
137
+
138
+ issues.each do |issue|
139
+ display_issue_row(issue)
140
+ end
141
+ puts
142
+ end
143
+
144
+ def display_issue_row(issue)
145
+ number = "##{issue.number}"
146
+ title = truncate(issue.title, 50)
147
+ labels = format_labels(issue.labels)
148
+ updated = format_time(issue.updated_at)
149
+
150
+ puts " %-6s | %-50s | %-20s | %s" % [number, title, labels, updated]
151
+ end
152
+
153
+ def format_labels(labels)
154
+ return "-" if labels.empty?
155
+
156
+ label_names = labels.map { |label| label[:name] }
157
+ truncate(label_names.join(", "), 20)
158
+ end
159
+
160
+ def format_time(time)
161
+ return "-" unless time
162
+
163
+ diff = Time.now - time
164
+ case diff
165
+ when 0...3600
166
+ "#{(diff / 60).to_i} mins ago"
167
+ when 3600...86400
168
+ "#{(diff / 3600).to_i} hours ago"
169
+ else
170
+ "#{(diff / 86400).to_i} days ago"
171
+ end
172
+ end
173
+
174
+ def truncate(text, max_length)
175
+ return text if text.length <= max_length
176
+
177
+ "#{text[0...max_length - 3]}..."
178
+ end
179
+
180
+ def log_execution_summary(issues)
181
+ logger.debug "Issue fetch completed",
182
+ repository: @repository,
183
+ issue_count: issues.count,
184
+ timestamp: Time.now.iso8601
185
+ end
186
+
187
+ def handle_rate_limit
188
+ # Wait for 1 minute before retrying
189
+ sleep(60) if running?
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'timeout'
5
+
6
+ module Soba
7
+ module Services
8
+ class PidManager
9
+ attr_reader :pid_file
10
+
11
+ def initialize(pid_file)
12
+ @pid_file = pid_file
13
+ end
14
+
15
+ def write(pid = Process.pid)
16
+ ensure_directory_exists
17
+ File.open(pid_file, 'w') do |f|
18
+ f.flock(File::LOCK_EX)
19
+ f.write(pid.to_s)
20
+ f.flush
21
+ end
22
+ end
23
+
24
+ def read
25
+ return nil unless File.exist?(pid_file)
26
+
27
+ content = File.read(pid_file).strip
28
+ return nil if content.empty?
29
+
30
+ pid = content.to_i
31
+ return nil if pid <= 0
32
+
33
+ pid
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def delete
39
+ return false unless File.exist?(pid_file)
40
+
41
+ File.delete(pid_file)
42
+ true
43
+ rescue StandardError
44
+ false
45
+ end
46
+
47
+ def running?
48
+ pid = read
49
+ return false unless pid
50
+
51
+ # Check if process exists
52
+ Process.kill(0, pid)
53
+ true
54
+ rescue Errno::ESRCH, Errno::EPERM
55
+ false
56
+ end
57
+
58
+ def cleanup_if_stale
59
+ return false unless File.exist?(pid_file)
60
+
61
+ if running?
62
+ false
63
+ else
64
+ delete
65
+ true
66
+ end
67
+ end
68
+
69
+ def lock(timeout: 5)
70
+ ensure_directory_exists
71
+ Timeout.timeout(timeout) do
72
+ File.open(pid_file, File::CREAT | File::WRONLY) do |f|
73
+ f.flock(File::LOCK_EX)
74
+ yield if block_given?
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def ensure_directory_exists
82
+ dir = File.dirname(pid_file)
83
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
84
+ end
85
+ end
86
+ end
87
+ end