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