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,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'English'
|
4
|
+
module Soba
|
5
|
+
module Services
|
6
|
+
class ProcessInfo
|
7
|
+
attr_reader :pid
|
8
|
+
|
9
|
+
def initialize(pid)
|
10
|
+
@pid = pid.to_i
|
11
|
+
end
|
12
|
+
|
13
|
+
def memory_usage_mb
|
14
|
+
return nil unless exists?
|
15
|
+
|
16
|
+
memory_kb = if File.exist?("/proc/#{pid}/status")
|
17
|
+
# Linux: Read from /proc filesystem
|
18
|
+
memory_from_proc
|
19
|
+
else
|
20
|
+
# macOS/other: Use ps command
|
21
|
+
memory_from_ps
|
22
|
+
end
|
23
|
+
|
24
|
+
memory_kb ? (memory_kb / 1024.0).round(2) : nil
|
25
|
+
rescue StandardError
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
def exists?
|
30
|
+
return false if pid <= 0
|
31
|
+
|
32
|
+
Process.kill(0, pid)
|
33
|
+
true
|
34
|
+
rescue Errno::ESRCH, Errno::EPERM
|
35
|
+
false
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def memory_from_proc
|
41
|
+
content = File.read("/proc/#{pid}/status")
|
42
|
+
# Look for VmRSS (Resident Set Size) in kilobytes
|
43
|
+
if content =~ /VmRSS:\s+(\d+)\s+kB/
|
44
|
+
Regexp.last_match(1).to_i
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def memory_from_ps
|
49
|
+
# ps -o rss= returns memory in kilobytes
|
50
|
+
output = `ps -o rss= -p #{pid} 2>/dev/null`.strip
|
51
|
+
|
52
|
+
if $CHILD_STATUS.success? && !output.empty?
|
53
|
+
output.to_i
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soba
|
4
|
+
module Services
|
5
|
+
class QueueingService
|
6
|
+
TODO_LABEL = "soba:todo"
|
7
|
+
QUEUED_LABEL = "soba:queued"
|
8
|
+
|
9
|
+
attr_reader :github_client, :blocking_checker, :logger
|
10
|
+
|
11
|
+
def initialize(github_client:, blocking_checker:, logger: nil)
|
12
|
+
@github_client = github_client
|
13
|
+
@blocking_checker = blocking_checker
|
14
|
+
@logger = logger || SemanticLogger["QueueingService"]
|
15
|
+
end
|
16
|
+
|
17
|
+
def queue_next_issue(repository)
|
18
|
+
logger.info("Starting queueing process: #{repository}")
|
19
|
+
|
20
|
+
if has_active_issue?(repository)
|
21
|
+
issues = github_client.issues(repository, state: "open")
|
22
|
+
reason = blocking_checker.blocking_reason(repository, issues: issues)
|
23
|
+
logger.info("Skipping queueing process: #{reason}")
|
24
|
+
return nil
|
25
|
+
end
|
26
|
+
|
27
|
+
candidate = find_next_candidate_from_repository(repository)
|
28
|
+
if candidate.nil?
|
29
|
+
logger.info("No issues found for queueing")
|
30
|
+
return nil
|
31
|
+
end
|
32
|
+
|
33
|
+
result = transition_to_queued(candidate, repository)
|
34
|
+
return nil if result.nil? # 競合状態検出時
|
35
|
+
|
36
|
+
candidate
|
37
|
+
rescue => e
|
38
|
+
logger.error("Error during queueing process: #{e.message} (repository: #{repository})")
|
39
|
+
raise
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def has_active_issue?(repository)
|
45
|
+
issues = github_client.issues(repository, state: "open")
|
46
|
+
blocking_checker.blocking?(repository, issues: issues)
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_next_candidate_from_repository(repository)
|
50
|
+
issues = github_client.issues(repository, state: "open")
|
51
|
+
find_next_candidate(issues)
|
52
|
+
end
|
53
|
+
|
54
|
+
def find_next_candidate(issues)
|
55
|
+
# アクティブまたは中間状態のsobaラベルを持つIssueが存在する場合はnilを返す
|
56
|
+
active_or_intermediate_issues = issues.select do |issue|
|
57
|
+
issue.labels.any? do |label|
|
58
|
+
label_name = label[:name]
|
59
|
+
WorkflowBlockingChecker::ACTIVE_LABELS.include?(label_name) ||
|
60
|
+
WorkflowBlockingChecker::INTERMEDIATE_LABELS.include?(label_name)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
return nil unless active_or_intermediate_issues.empty?
|
65
|
+
|
66
|
+
todo_issues = issues.select do |issue|
|
67
|
+
issue.labels.any? { |label| label[:name] == TODO_LABEL }
|
68
|
+
end
|
69
|
+
|
70
|
+
return nil if todo_issues.empty?
|
71
|
+
|
72
|
+
# Issue番号の昇順でソートして最初の1件を返す
|
73
|
+
todo_issues.min_by(&:number)
|
74
|
+
end
|
75
|
+
|
76
|
+
def transition_to_queued(issue, repository)
|
77
|
+
# ラベル更新直前に再度排他制御チェック(競合状態の検出)
|
78
|
+
current_issues = github_client.issues(repository, state: "open")
|
79
|
+
if blocking_checker.blocking?(repository, issues: current_issues)
|
80
|
+
reason = blocking_checker.blocking_reason(repository, issues: current_issues)
|
81
|
+
logger.warn("Race condition detected: #{reason}")
|
82
|
+
logger.warn("Skipping queueing for Issue ##{issue.number}")
|
83
|
+
return nil
|
84
|
+
end
|
85
|
+
|
86
|
+
logger.debug("Updating labels for Issue ##{issue.number}: #{TODO_LABEL} -> #{QUEUED_LABEL}")
|
87
|
+
|
88
|
+
github_client.update_issue_labels(repository, issue.number, from: TODO_LABEL, to: QUEUED_LABEL)
|
89
|
+
|
90
|
+
logger.info("Transitioned Issue ##{issue.number} to soba:queued: #{issue.title}")
|
91
|
+
true # 成功を示すために true を返す
|
92
|
+
rescue => e
|
93
|
+
logger.error("Failed to update labels for Issue ##{issue.number}: #{e.message}")
|
94
|
+
raise
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module Soba
|
7
|
+
module Services
|
8
|
+
# セッション出力のログ管理サービス
|
9
|
+
class SessionLogger
|
10
|
+
DEFAULT_LOG_DIR = File.join(Dir.home, ".soba", "logs")
|
11
|
+
DEFAULT_MAX_SIZE = 10 * 1024 * 1024 # 10MB
|
12
|
+
|
13
|
+
attr_reader :log_dir
|
14
|
+
|
15
|
+
def initialize(log_dir: nil, max_size: DEFAULT_MAX_SIZE)
|
16
|
+
@log_dir = log_dir || DEFAULT_LOG_DIR
|
17
|
+
@max_size = max_size
|
18
|
+
@file_handles = {}
|
19
|
+
|
20
|
+
ensure_log_directory
|
21
|
+
end
|
22
|
+
|
23
|
+
# ログファイルに書き込み
|
24
|
+
def write(session_name, content)
|
25
|
+
ensure_file_handle(session_name)
|
26
|
+
|
27
|
+
timestamp = Time.now.strftime("[%Y-%m-%d %H:%M:%S]")
|
28
|
+
@file_handles[session_name].write("#{timestamp} #{content}")
|
29
|
+
@file_handles[session_name].flush
|
30
|
+
|
31
|
+
# ローテーションチェック
|
32
|
+
check_rotation(session_name)
|
33
|
+
end
|
34
|
+
|
35
|
+
# セッションのログファイルパスを取得
|
36
|
+
def find_log(session_name)
|
37
|
+
log_file = File.join(@log_dir, "#{session_name}.log")
|
38
|
+
File.exist?(log_file) ? log_file : nil
|
39
|
+
end
|
40
|
+
|
41
|
+
# ログが存在するセッション一覧
|
42
|
+
def list_sessions
|
43
|
+
Dir.glob(File.join(@log_dir, "soba-*.log")).
|
44
|
+
reject { |f| f.include?(".log.") }. # ローテートファイルを除外
|
45
|
+
map { |f| File.basename(f, ".log") }.
|
46
|
+
sort
|
47
|
+
end
|
48
|
+
|
49
|
+
# ファイルハンドルをクローズ
|
50
|
+
def close
|
51
|
+
@file_handles.each_value(&:close)
|
52
|
+
@file_handles.clear
|
53
|
+
end
|
54
|
+
|
55
|
+
# 古いログファイルのクリーンアップ
|
56
|
+
def cleanup_old_logs(days: 30)
|
57
|
+
cutoff_time = Time.now - (days * 24 * 60 * 60)
|
58
|
+
|
59
|
+
Dir.glob(File.join(@log_dir, "soba-*.log*")).each do |file|
|
60
|
+
if File.mtime(file) < cutoff_time
|
61
|
+
File.delete(file)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# ログディレクトリの確認と作成
|
69
|
+
def ensure_log_directory
|
70
|
+
FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
|
71
|
+
end
|
72
|
+
|
73
|
+
# ファイルハンドルの取得または作成
|
74
|
+
def ensure_file_handle(session_name)
|
75
|
+
return if @file_handles[session_name]
|
76
|
+
|
77
|
+
log_file = File.join(@log_dir, "#{session_name}.log")
|
78
|
+
@file_handles[session_name] = File.open(log_file, "a")
|
79
|
+
end
|
80
|
+
|
81
|
+
# ログローテーションのチェック
|
82
|
+
def check_rotation(session_name)
|
83
|
+
log_file = File.join(@log_dir, "#{session_name}.log")
|
84
|
+
return unless File.size(log_file) > @max_size
|
85
|
+
|
86
|
+
rotate_log(session_name)
|
87
|
+
end
|
88
|
+
|
89
|
+
# ログファイルのローテート
|
90
|
+
def rotate_log(session_name)
|
91
|
+
log_file = File.join(@log_dir, "#{session_name}.log")
|
92
|
+
|
93
|
+
# ファイルハンドルを閉じる
|
94
|
+
@file_handles[session_name]&.close
|
95
|
+
@file_handles.delete(session_name)
|
96
|
+
|
97
|
+
# ローテート番号を決定
|
98
|
+
rotate_number = 1
|
99
|
+
while File.exist?("#{log_file}.#{rotate_number}")
|
100
|
+
rotate_number += 1
|
101
|
+
end
|
102
|
+
|
103
|
+
# ファイルを移動
|
104
|
+
File.rename(log_file, "#{log_file}.#{rotate_number}")
|
105
|
+
|
106
|
+
# 新しいファイルハンドルを開く
|
107
|
+
ensure_file_handle(session_name)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Soba
|
4
|
+
module Services
|
5
|
+
class SessionResolver
|
6
|
+
def initialize(pid_manager: nil, tmux_manager: nil)
|
7
|
+
@pid_manager = pid_manager || PidManager.new
|
8
|
+
@tmux_manager = tmux_manager || TmuxSessionManager.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def resolve_active_session(repository)
|
12
|
+
pid = @pid_manager.read
|
13
|
+
return nil unless pid
|
14
|
+
|
15
|
+
session_name = generate_session_name(repository, pid)
|
16
|
+
|
17
|
+
if @tmux_manager.session_exists?(session_name)
|
18
|
+
session_name
|
19
|
+
else
|
20
|
+
@pid_manager.delete
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
rescue Errno::ENOENT
|
24
|
+
nil
|
25
|
+
end
|
26
|
+
|
27
|
+
def find_all_repository_sessions(repository)
|
28
|
+
pid = @pid_manager.read
|
29
|
+
return [] unless pid
|
30
|
+
|
31
|
+
session_name = generate_session_name(repository, pid)
|
32
|
+
active = @tmux_manager.session_exists?(session_name)
|
33
|
+
|
34
|
+
unless active
|
35
|
+
@pid_manager.delete
|
36
|
+
end
|
37
|
+
|
38
|
+
[
|
39
|
+
{
|
40
|
+
name: session_name,
|
41
|
+
pid: pid,
|
42
|
+
active: active,
|
43
|
+
},
|
44
|
+
]
|
45
|
+
end
|
46
|
+
|
47
|
+
def generate_session_name(repository, pid)
|
48
|
+
raise ArgumentError, "PID cannot be nil" if pid.nil?
|
49
|
+
|
50
|
+
sanitized_repo = repository.to_s.gsub(/[^a-zA-Z0-9-]/, "-")
|
51
|
+
"soba-#{sanitized_repo}-#{pid}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def cleanup_stale_sessions(repository)
|
55
|
+
pid = @pid_manager.read
|
56
|
+
return [] unless pid
|
57
|
+
|
58
|
+
session_name = generate_session_name(repository, pid)
|
59
|
+
if @tmux_manager.session_exists?(session_name)
|
60
|
+
[]
|
61
|
+
else
|
62
|
+
@pid_manager.delete
|
63
|
+
[pid]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
attr_reader :pid_manager, :tmux_manager
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Soba
|
7
|
+
module Services
|
8
|
+
class SlackNotifier
|
9
|
+
def initialize(webhook_url:)
|
10
|
+
@webhook_url = webhook_url
|
11
|
+
end
|
12
|
+
|
13
|
+
def notify_phase_start(issue_data)
|
14
|
+
return false unless enabled?
|
15
|
+
|
16
|
+
logger.debug "Starting Slack notification for issue ##{issue_data[:number]}, phase: #{issue_data[:phase]}"
|
17
|
+
|
18
|
+
begin
|
19
|
+
message = build_message(issue_data)
|
20
|
+
logger.debug "Sending notification to Slack webhook"
|
21
|
+
|
22
|
+
response = send_notification(message)
|
23
|
+
|
24
|
+
if response.success?
|
25
|
+
logger.debug "Slack notification sent successfully (HTTP #{response.status})"
|
26
|
+
true
|
27
|
+
else
|
28
|
+
logger.warn("Failed to send Slack notification: HTTP #{response.status}")
|
29
|
+
false
|
30
|
+
end
|
31
|
+
rescue StandardError => e
|
32
|
+
logger.warn("Error sending Slack notification: #{e.message}")
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def enabled?
|
38
|
+
@webhook_url.present?
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.from_env
|
42
|
+
new(webhook_url: ENV["SLACK_WEBHOOK_URL"])
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.from_config
|
46
|
+
config = Soba::Configuration.config
|
47
|
+
return unless config.slack.notifications_enabled
|
48
|
+
|
49
|
+
webhook_url = config.slack.webhook_url
|
50
|
+
# 環境変数形式の場合は展開
|
51
|
+
if webhook_url&.match?(/\$\{([^}]+)\}/)
|
52
|
+
var_name = webhook_url.match(/\$\{([^}]+)\}/)[1]
|
53
|
+
webhook_url = ENV[var_name]
|
54
|
+
end
|
55
|
+
|
56
|
+
new(webhook_url: webhook_url)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def send_notification(message)
|
62
|
+
connection = Faraday.new do |conn|
|
63
|
+
conn.request :json
|
64
|
+
conn.response :json
|
65
|
+
conn.adapter Faraday.default_adapter
|
66
|
+
conn.options.timeout = 5
|
67
|
+
conn.options.open_timeout = 5
|
68
|
+
end
|
69
|
+
|
70
|
+
connection.post(@webhook_url, message.to_json)
|
71
|
+
end
|
72
|
+
|
73
|
+
def build_message(issue_data)
|
74
|
+
issue_url = if issue_data[:repository]
|
75
|
+
"https://github.com/#{issue_data[:repository]}/issues/#{issue_data[:number]}"
|
76
|
+
else
|
77
|
+
"##{issue_data[:number]}"
|
78
|
+
end
|
79
|
+
|
80
|
+
issue_value = if issue_data[:repository]
|
81
|
+
"<#{issue_url}|##{issue_data[:number]}>"
|
82
|
+
else
|
83
|
+
"##{issue_data[:number]}"
|
84
|
+
end
|
85
|
+
|
86
|
+
{
|
87
|
+
text: "🚀 Soba started #{issue_data[:phase]} phase: Issue ##{issue_data[:number]}",
|
88
|
+
attachments: [
|
89
|
+
{
|
90
|
+
color: "good",
|
91
|
+
title: issue_data[:title],
|
92
|
+
fields: [
|
93
|
+
{
|
94
|
+
title: "Issue",
|
95
|
+
value: issue_value,
|
96
|
+
short: true,
|
97
|
+
},
|
98
|
+
{
|
99
|
+
title: "Phase",
|
100
|
+
value: issue_data[:phase],
|
101
|
+
short: true,
|
102
|
+
},
|
103
|
+
],
|
104
|
+
footer: "Soba CLI",
|
105
|
+
footer_icon: "https://github.com/favicon.ico",
|
106
|
+
ts: Time.now.to_i,
|
107
|
+
},
|
108
|
+
],
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
def logger
|
113
|
+
@logger ||= if defined?(Soba.logger)
|
114
|
+
Soba.logger
|
115
|
+
else
|
116
|
+
SemanticLogger["SlackNotifier"]
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'time'
|
6
|
+
|
7
|
+
module Soba
|
8
|
+
module Services
|
9
|
+
class StatusManager
|
10
|
+
attr_reader :status_file
|
11
|
+
|
12
|
+
def initialize(status_file)
|
13
|
+
@status_file = status_file
|
14
|
+
end
|
15
|
+
|
16
|
+
def write(data)
|
17
|
+
dir = File.dirname(status_file)
|
18
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
19
|
+
|
20
|
+
# Atomic write using temp file
|
21
|
+
temp_file = "#{status_file}.tmp"
|
22
|
+
File.write(temp_file, JSON.pretty_generate(data))
|
23
|
+
File.rename(temp_file, status_file)
|
24
|
+
rescue StandardError => e
|
25
|
+
# Clean up temp file if something goes wrong
|
26
|
+
FileUtils.rm_f(temp_file) if defined?(temp_file)
|
27
|
+
raise e
|
28
|
+
end
|
29
|
+
|
30
|
+
def read
|
31
|
+
return nil unless File.exist?(status_file)
|
32
|
+
|
33
|
+
content = File.read(status_file)
|
34
|
+
return nil if content.empty?
|
35
|
+
|
36
|
+
JSON.parse(content, symbolize_names: true)
|
37
|
+
rescue JSON::ParserError, StandardError
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def update_current_issue(issue_number, phase)
|
42
|
+
data = read || {}
|
43
|
+
data[:current_issue] = {
|
44
|
+
number: issue_number,
|
45
|
+
phase: phase,
|
46
|
+
started_at: Time.now.iso8601,
|
47
|
+
}
|
48
|
+
write(data)
|
49
|
+
end
|
50
|
+
|
51
|
+
def update_last_processed
|
52
|
+
data = read || {}
|
53
|
+
if data[:current_issue]
|
54
|
+
data[:last_processed] = {
|
55
|
+
number: data[:current_issue][:number],
|
56
|
+
completed_at: Time.now.iso8601,
|
57
|
+
}
|
58
|
+
data.delete(:current_issue)
|
59
|
+
end
|
60
|
+
write(data)
|
61
|
+
end
|
62
|
+
|
63
|
+
def update_memory(memory_mb)
|
64
|
+
data = read || {}
|
65
|
+
data[:memory_mb] = memory_mb
|
66
|
+
write(data)
|
67
|
+
end
|
68
|
+
|
69
|
+
def clear
|
70
|
+
FileUtils.rm_f(status_file)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'fileutils'
|
5
|
+
require_relative 'pid_manager'
|
6
|
+
|
7
|
+
module Soba
|
8
|
+
module Services
|
9
|
+
class TestProcessManager
|
10
|
+
TEST_PID_DIR = '/tmp/soba-test-pids'
|
11
|
+
|
12
|
+
def test_mode?
|
13
|
+
ENV['SOBA_TEST_MODE'] == 'true'
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate_test_session_name(repository)
|
17
|
+
sanitized_repo = repository.gsub(/[\/._]/, '-')
|
18
|
+
"soba-test-#{sanitized_repo}-#{generate_test_id}"
|
19
|
+
end
|
20
|
+
|
21
|
+
def generate_test_id
|
22
|
+
"#{Process.pid}-#{SecureRandom.hex(4)}"
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_pid_file_path(test_id)
|
26
|
+
"#{TEST_PID_DIR}/#{test_id}.pid"
|
27
|
+
end
|
28
|
+
|
29
|
+
def create_test_pid_manager(test_id)
|
30
|
+
pid_file = test_pid_file_path(test_id)
|
31
|
+
PidManager.new(pid_file)
|
32
|
+
end
|
33
|
+
|
34
|
+
def cleanup_test_processes(test_id, timeout: 10)
|
35
|
+
pid_manager = create_test_pid_manager(test_id)
|
36
|
+
cleaned_processes = []
|
37
|
+
|
38
|
+
pid = pid_manager.read
|
39
|
+
return { success: true, cleaned_processes: cleaned_processes } unless pid
|
40
|
+
|
41
|
+
if pid_manager.running?
|
42
|
+
begin
|
43
|
+
# Graceful termination
|
44
|
+
Process.kill('TERM', pid)
|
45
|
+
|
46
|
+
# Wait for graceful shutdown
|
47
|
+
wait_time = 0
|
48
|
+
while wait_time < timeout && pid_manager.running?
|
49
|
+
sleep(0.1)
|
50
|
+
wait_time += 0.1
|
51
|
+
end
|
52
|
+
|
53
|
+
# Force kill if still running
|
54
|
+
if pid_manager.running?
|
55
|
+
Process.kill('KILL', pid)
|
56
|
+
sleep(0.1) # Brief wait for force kill
|
57
|
+
end
|
58
|
+
|
59
|
+
cleaned_processes << pid
|
60
|
+
rescue Errno::ESRCH, Errno::EPERM
|
61
|
+
# Process already dead or no permission
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Clean up PID file
|
66
|
+
pid_manager.delete
|
67
|
+
|
68
|
+
{ success: true, cleaned_processes: cleaned_processes }
|
69
|
+
rescue StandardError => e
|
70
|
+
{ success: false, error: e.message, cleaned_processes: cleaned_processes }
|
71
|
+
end
|
72
|
+
|
73
|
+
def ensure_test_environment
|
74
|
+
if test_mode?
|
75
|
+
FileUtils.mkdir_p(TEST_PID_DIR)
|
76
|
+
end
|
77
|
+
|
78
|
+
{ success: true, test_mode: test_mode? }
|
79
|
+
rescue StandardError => e
|
80
|
+
{ success: false, error: e.message, test_mode: test_mode? }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|