soba-cli 0.1.1 → 0.1.2
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 +4 -4
- data/.rubocop.yml +8 -1
- data/README.md +32 -3
- data/README_ja.md +32 -3
- data/config/config.yml.example +5 -1
- data/lib/soba/commands/init.rb +197 -129
- data/lib/soba/configuration.rb +36 -3
- data/lib/soba/infrastructure/github_client.rb +22 -2
- data/lib/soba/infrastructure/github_token_provider.rb +98 -0
- data/lib/soba/services/auto_merge_service.rb +45 -2
- data/lib/soba/services/slack_notifier.rb +78 -0
- data/lib/soba/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a8267febd9095e47d80e11b731840e4b82dabd17310d2cf82599315dbf98fa40
|
4
|
+
data.tar.gz: c5cb07a9ae55bea658a6afdad842fcb8eed8c3576ed19ac8dab7ba8d5b7fd336
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 163c95711c54b4ea7de1cc3bb0ff8d7a35108fbf4d8eac27cdde308e71b2a64d0c99f7e04fa455369d5a8b8e7526fff82092b60d0ed479980fd4818272b598e7
|
7
|
+
data.tar.gz: f9e3dbe879dcdb815542152ce5c6ec1efc40b4147b94bcdc5d55f1ad579b635eac8b1f7733b542898831d813edcf003a924bda4f3f45250120a37814882ae7e6
|
data/.rubocop.yml
CHANGED
@@ -48,6 +48,13 @@ Style/GlobalVars:
|
|
48
48
|
Exclude:
|
49
49
|
- 'bin/soba'
|
50
50
|
|
51
|
+
# Disable Rails-specific rules (pure Ruby CLI, not Rails app)
|
52
|
+
Rails/NegateInclude:
|
53
|
+
Enabled: false # Use !include? instead of Rails' exclude?
|
54
|
+
|
55
|
+
Rails/Present:
|
56
|
+
Enabled: false # Use standard Ruby methods instead of Rails' present?
|
57
|
+
|
51
58
|
# For RSpec
|
52
59
|
RSpec/MultipleExpectations:
|
53
60
|
Max: 3
|
@@ -62,7 +69,7 @@ RSpec/NestedGroups:
|
|
62
69
|
Airbnb/ClassOrModuleDeclaredInWrongFile:
|
63
70
|
Enabled: false
|
64
71
|
|
65
|
-
#
|
72
|
+
# Disable ActiveRecord-related cops (not applicable to CLI)
|
66
73
|
Rails/Pluck:
|
67
74
|
Enabled: false
|
68
75
|
|
data/README.md
CHANGED
@@ -81,7 +81,11 @@ When using default settings, please take necessary precautions such as using dev
|
|
81
81
|
Edit `.soba/config.yml`:
|
82
82
|
```yaml
|
83
83
|
github:
|
84
|
-
|
84
|
+
# Use gh command authentication (if available)
|
85
|
+
auth_method: gh
|
86
|
+
# Or use environment variable
|
87
|
+
# auth_method: env
|
88
|
+
# token: ${GITHUB_TOKEN}
|
85
89
|
repository: owner/repo
|
86
90
|
```
|
87
91
|
|
@@ -97,13 +101,38 @@ When using default settings, please take necessary precautions such as using dev
|
|
97
101
|
|
98
102
|
Configuration file location: `.soba/config.yml` (in project root)
|
99
103
|
|
104
|
+
### GitHub Authentication
|
105
|
+
|
106
|
+
soba supports multiple authentication methods:
|
107
|
+
|
108
|
+
1. **GitHub CLI (gh command)** - Recommended
|
109
|
+
- Uses existing `gh` authentication
|
110
|
+
- No need to manage tokens in config files
|
111
|
+
- Set `auth_method: gh` in config
|
112
|
+
|
113
|
+
2. **Environment Variable**
|
114
|
+
- Uses `GITHUB_TOKEN` environment variable
|
115
|
+
- Set `auth_method: env` in config
|
116
|
+
|
117
|
+
3. **Auto-detect** (Default)
|
118
|
+
- Automatically tries `gh` command first
|
119
|
+
- Falls back to environment variable if `gh` is not available
|
120
|
+
- Omit `auth_method` field for auto-detection
|
121
|
+
|
100
122
|
### Full Configuration Example
|
101
123
|
|
102
124
|
```yaml
|
103
125
|
# GitHub settings
|
104
126
|
github:
|
105
|
-
#
|
106
|
-
token
|
127
|
+
# Authentication method: 'gh', 'env', or omit for auto-detect
|
128
|
+
# Use 'gh' to use GitHub CLI authentication (gh auth token)
|
129
|
+
# Use 'env' to use environment variable
|
130
|
+
auth_method: gh # or 'env', or omit for auto-detect
|
131
|
+
|
132
|
+
# Personal Access Token (required when auth_method is 'env' or omitted)
|
133
|
+
# Can use environment variable
|
134
|
+
# token: ${GITHUB_TOKEN}
|
135
|
+
|
107
136
|
# Target repository (format: owner/repo)
|
108
137
|
repository: douhashi/soba-cli
|
109
138
|
|
data/README_ja.md
CHANGED
@@ -81,7 +81,11 @@ soba はAIによる自律的な開発を支援するため、デフォルトの
|
|
81
81
|
`.soba/config.yml`を編集:
|
82
82
|
```yaml
|
83
83
|
github:
|
84
|
-
|
84
|
+
# ghコマンド認証を使用(利用可能な場合)
|
85
|
+
auth_method: gh
|
86
|
+
# または環境変数を使用
|
87
|
+
# auth_method: env
|
88
|
+
# token: ${GITHUB_TOKEN}
|
85
89
|
repository: owner/repo
|
86
90
|
```
|
87
91
|
|
@@ -97,13 +101,38 @@ soba はAIによる自律的な開発を支援するため、デフォルトの
|
|
97
101
|
|
98
102
|
設定ファイルの場所: `.soba/config.yml`(プロジェクトルート)
|
99
103
|
|
104
|
+
### GitHub認証
|
105
|
+
|
106
|
+
sobaは複数の認証方法をサポートしています:
|
107
|
+
|
108
|
+
1. **GitHub CLI (ghコマンド)** - 推奨
|
109
|
+
- 既存の`gh`認証を利用
|
110
|
+
- 設定ファイルでトークンを管理する必要がない
|
111
|
+
- 設定で`auth_method: gh`を指定
|
112
|
+
|
113
|
+
2. **環境変数**
|
114
|
+
- `GITHUB_TOKEN`環境変数を利用
|
115
|
+
- 設定で`auth_method: env`を指定
|
116
|
+
|
117
|
+
3. **自動検出** (デフォルト)
|
118
|
+
- 最初に`gh`コマンドを試行
|
119
|
+
- `gh`が利用できない場合は環境変数にフォールバック
|
120
|
+
- 自動検出には`auth_method`フィールドを省略
|
121
|
+
|
100
122
|
### 完全な設定例
|
101
123
|
|
102
124
|
```yaml
|
103
125
|
# GitHub設定
|
104
126
|
github:
|
105
|
-
#
|
106
|
-
token
|
127
|
+
# 認証方法: 'gh'、'env'、または省略して自動検出
|
128
|
+
# 'gh'を使用してGitHub CLI認証を利用(gh auth token)
|
129
|
+
# 'env'を使用して環境変数を利用
|
130
|
+
auth_method: gh # または'env'、省略で自動検出
|
131
|
+
|
132
|
+
# Personal Access Token(auth_methodが'env'または省略時に必要)
|
133
|
+
# 環境変数を使用可能
|
134
|
+
# token: ${GITHUB_TOKEN}
|
135
|
+
|
107
136
|
# ターゲットリポジトリ(形式: owner/repo)
|
108
137
|
repository: douhashi/soba-cli
|
109
138
|
|
data/config/config.yml.example
CHANGED
@@ -1,7 +1,11 @@
|
|
1
1
|
# soba CLI configuration example
|
2
2
|
github:
|
3
|
-
#
|
3
|
+
# Authentication method: 'gh', 'env', or omit for auto-detect
|
4
|
+
# auth_method: gh
|
5
|
+
|
6
|
+
# GitHub Personal Access Token (used when auth_method is 'env' or omitted)
|
4
7
|
# Generate at: https://github.com/settings/tokens
|
8
|
+
# If auth_method is 'gh', this field can be omitted
|
5
9
|
token: ${GITHUB_TOKEN}
|
6
10
|
|
7
11
|
# Default repository to monitor (owner/repo format)
|
data/lib/soba/commands/init.rb
CHANGED
@@ -7,6 +7,7 @@ require "pathname"
|
|
7
7
|
require "yaml"
|
8
8
|
require "io/console"
|
9
9
|
require_relative "../infrastructure/github_client"
|
10
|
+
require_relative "../infrastructure/github_token_provider"
|
10
11
|
|
11
12
|
module Soba
|
12
13
|
module Commands
|
@@ -139,10 +140,29 @@ module Soba
|
|
139
140
|
raise Soba::CommandError, "Cannot detect GitHub repository"
|
140
141
|
end
|
141
142
|
|
143
|
+
# Detect best authentication method
|
144
|
+
token_provider = Soba::Infrastructure::GitHubTokenProvider.new
|
145
|
+
detected_method = token_provider.detect_best_method
|
146
|
+
|
142
147
|
# Create configuration with default values
|
143
148
|
config = DEFAULT_CONFIG.deep_dup
|
144
149
|
config['github']['repository'] = repository
|
145
150
|
|
151
|
+
# Set authentication based on detection
|
152
|
+
if detected_method == 'gh'
|
153
|
+
config['github']['auth_method'] = 'gh'
|
154
|
+
config['github'].delete('token')
|
155
|
+
puts "✅ Using gh command authentication (detected)"
|
156
|
+
elsif detected_method == 'env'
|
157
|
+
config['github']['auth_method'] = 'env'
|
158
|
+
config['github']['token'] = '${GITHUB_TOKEN}'
|
159
|
+
puts "✅ Using environment variable authentication"
|
160
|
+
else
|
161
|
+
# Keep default token field for backward compatibility
|
162
|
+
config['github']['token'] = '${GITHUB_TOKEN}'
|
163
|
+
puts "⚠️ No authentication method detected. Please configure manually."
|
164
|
+
end
|
165
|
+
|
146
166
|
# Add default phase configuration
|
147
167
|
config['phase'] = DEFAULT_PHASE_CONFIG.deep_dup
|
148
168
|
|
@@ -187,28 +207,70 @@ module Soba
|
|
187
207
|
repository = default_repo
|
188
208
|
end
|
189
209
|
|
190
|
-
while repository.blank? || repository.
|
210
|
+
while repository.blank? || !repository.include?('/')
|
191
211
|
puts "❌ Invalid format. Please use: owner/repo"
|
192
212
|
print "Enter GitHub repository: "
|
193
213
|
repository = $stdin.gets.chomp
|
194
214
|
end
|
195
215
|
|
196
|
-
# GitHub
|
216
|
+
# GitHub authentication detection
|
197
217
|
puts ""
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
218
|
+
token_provider = Soba::Infrastructure::GitHubTokenProvider.new
|
219
|
+
gh_available = token_provider.gh_available?
|
220
|
+
|
221
|
+
if gh_available
|
222
|
+
puts "✅ gh command is available and authenticated"
|
223
|
+
elsif ENV['GITHUB_TOKEN']
|
224
|
+
puts "✅ GITHUB_TOKEN environment variable is set"
|
225
|
+
else
|
226
|
+
puts "⚠️ Neither gh command nor GITHUB_TOKEN environment variable is available"
|
227
|
+
end
|
228
|
+
|
229
|
+
# GitHub token setup
|
230
|
+
puts ""
|
231
|
+
puts "GitHub authentication setup:"
|
232
|
+
|
233
|
+
auth_method = nil
|
234
|
+
token = nil
|
235
|
+
|
236
|
+
if gh_available
|
237
|
+
puts " 1. Use environment variable ${GITHUB_TOKEN}"
|
238
|
+
puts " 2. Enter token directly (will be visible in config file)"
|
239
|
+
puts " 3. Use gh command authentication (detected)"
|
240
|
+
print "Choose option (1-3) [3]: "
|
241
|
+
token_option = $stdin.gets.chomp
|
242
|
+
token_option = '3' if token_option.empty?
|
243
|
+
|
244
|
+
case token_option
|
245
|
+
when '2'
|
246
|
+
print "Enter GitHub token: "
|
247
|
+
# Hide input for security
|
248
|
+
token = $stdin.noecho(&:gets).chomp.tap { puts }
|
249
|
+
auth_method = nil
|
250
|
+
when '3'
|
251
|
+
auth_method = 'gh'
|
252
|
+
token = nil
|
253
|
+
else
|
254
|
+
token = '${GITHUB_TOKEN}'
|
255
|
+
auth_method = 'env'
|
256
|
+
end
|
257
|
+
else
|
258
|
+
puts " 1. Use environment variable ${GITHUB_TOKEN} (recommended)"
|
259
|
+
puts " 2. Enter token directly (will be visible in config file)"
|
260
|
+
print "Choose option (1-2) [1]: "
|
261
|
+
token_option = $stdin.gets.chomp
|
262
|
+
token_option = '1' if token_option.empty?
|
263
|
+
|
264
|
+
if token_option == '2'
|
265
|
+
print "Enter GitHub token: "
|
266
|
+
# Hide input for security
|
267
|
+
token = $stdin.noecho(&:gets).chomp.tap { puts }
|
268
|
+
auth_method = nil
|
269
|
+
else
|
270
|
+
token = '${GITHUB_TOKEN}'
|
271
|
+
auth_method = 'env'
|
272
|
+
end
|
273
|
+
end
|
212
274
|
|
213
275
|
# Polling interval
|
214
276
|
puts ""
|
@@ -366,7 +428,6 @@ module Soba
|
|
366
428
|
# Create configuration
|
367
429
|
config = {
|
368
430
|
'github' => {
|
369
|
-
'token' => token,
|
370
431
|
'repository' => repository,
|
371
432
|
},
|
372
433
|
'workflow' => {
|
@@ -395,30 +456,44 @@ module Soba
|
|
395
456
|
},
|
396
457
|
}
|
397
458
|
|
398
|
-
# Add
|
459
|
+
# Add token/auth_method based on selection
|
460
|
+
if auth_method
|
461
|
+
config['github']['auth_method'] = auth_method
|
462
|
+
# For env auth method, we still need the token field
|
463
|
+
if auth_method == 'env' || token
|
464
|
+
config['github']['token'] = token
|
465
|
+
end
|
466
|
+
else
|
467
|
+
config['github']['token'] = token
|
468
|
+
end
|
469
|
+
|
470
|
+
# Add phase configuration only if any phase command is provided
|
399
471
|
if plan_command || implement_command || review_command
|
400
472
|
config['phase'] = {}
|
401
473
|
|
474
|
+
# Add plan phase if command is provided
|
402
475
|
if plan_command
|
403
476
|
config['phase']['plan'] = {
|
404
477
|
'command' => plan_command,
|
405
|
-
'options' => plan_options,
|
478
|
+
'options' => plan_options || [],
|
406
479
|
'parameter' => plan_parameter,
|
407
480
|
}
|
408
481
|
end
|
409
482
|
|
483
|
+
# Add implement phase if command is provided
|
410
484
|
if implement_command
|
411
485
|
config['phase']['implement'] = {
|
412
486
|
'command' => implement_command,
|
413
|
-
'options' => implement_options,
|
487
|
+
'options' => implement_options || [],
|
414
488
|
'parameter' => implement_parameter,
|
415
489
|
}
|
416
490
|
end
|
417
491
|
|
492
|
+
# Add review phase if command is provided
|
418
493
|
if review_command
|
419
494
|
config['phase']['review'] = {
|
420
495
|
'command' => review_command,
|
421
|
-
'options' => review_options,
|
496
|
+
'options' => review_options || [],
|
422
497
|
'parameter' => review_parameter,
|
423
498
|
}
|
424
499
|
end
|
@@ -445,125 +520,118 @@ module Soba
|
|
445
520
|
|
446
521
|
def write_config_file(config_path, config)
|
447
522
|
config_path.dirname.mkpath
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
auto_merge_enabled: #{config['workflow']['auto_merge_enabled']}
|
467
|
-
|
468
|
-
# Enable automatic cleanup of tmux windows for closed issues
|
469
|
-
closed_issue_cleanup_enabled: #{config['workflow']['closed_issue_cleanup_enabled']}
|
470
|
-
|
471
|
-
# Cleanup check interval in seconds
|
472
|
-
closed_issue_cleanup_interval: #{config['workflow']['closed_issue_cleanup_interval']}
|
473
|
-
|
474
|
-
# Delay (in seconds) before sending commands to new tmux panes/windows
|
475
|
-
tmux_command_delay: #{config['workflow']['tmux_command_delay']}
|
476
|
-
|
477
|
-
# Phase labels for tracking issue progress
|
478
|
-
phase_labels:
|
479
|
-
todo: #{config['workflow']['phase_labels']['todo']}
|
480
|
-
queued: #{config['workflow']['phase_labels']['queued']}
|
481
|
-
planning: #{config['workflow']['phase_labels']['planning']}
|
482
|
-
ready: #{config['workflow']['phase_labels']['ready']}
|
483
|
-
doing: #{config['workflow']['phase_labels']['doing']}
|
484
|
-
review_requested: #{config['workflow']['phase_labels']['review_requested']}
|
485
|
-
reviewing: #{config['workflow']['phase_labels']['reviewing']}
|
486
|
-
done: #{config['workflow']['phase_labels']['done']}
|
487
|
-
requires_changes: #{config['workflow']['phase_labels']['requires_changes']}
|
488
|
-
revising: #{config['workflow']['phase_labels']['revising']}
|
489
|
-
merged: #{config['workflow']['phase_labels']['merged']}
|
490
|
-
|
491
|
-
# Slack notification configuration
|
492
|
-
slack:
|
493
|
-
# Slack Webhook URL for notifications
|
494
|
-
# Can use environment variable: ${SLACK_WEBHOOK_URL}
|
495
|
-
webhook_url: #{config['slack']['webhook_url']}
|
496
|
-
|
497
|
-
# Enable Slack notifications for phase starts
|
498
|
-
notifications_enabled: #{config['slack']['notifications_enabled']}
|
499
|
-
YAML
|
523
|
+
|
524
|
+
# Build the complete configuration structure
|
525
|
+
yaml_config = {
|
526
|
+
'github' => {},
|
527
|
+
'workflow' => config['workflow'],
|
528
|
+
'slack' => config['slack'],
|
529
|
+
}
|
530
|
+
|
531
|
+
# Add github configuration with proper fields
|
532
|
+
if config['github']['auth_method']
|
533
|
+
yaml_config['github']['auth_method'] = config['github']['auth_method']
|
534
|
+
end
|
535
|
+
|
536
|
+
if config['github']['token']
|
537
|
+
yaml_config['github']['token'] = config['github']['token']
|
538
|
+
end
|
539
|
+
|
540
|
+
yaml_config['github']['repository'] = config['github']['repository']
|
500
541
|
|
501
542
|
# Add phase configuration if present
|
502
|
-
if config['phase']
|
503
|
-
|
504
|
-
|
505
|
-
if config['phase']['plan']
|
506
|
-
phase_content += " plan:\n"
|
507
|
-
phase_content += " command: #{config['phase']['plan']['command']}\n"
|
508
|
-
if config['phase']['plan']['options'].present?
|
509
|
-
phase_content += " options:\n"
|
510
|
-
config['phase']['plan']['options'].each do |opt|
|
511
|
-
phase_content += " - #{opt}\n"
|
512
|
-
end
|
513
|
-
end
|
514
|
-
if config['phase']['plan']['parameter']
|
515
|
-
phase_content += " parameter: '#{config['phase']['plan']['parameter']}'\n"
|
516
|
-
end
|
517
|
-
end
|
543
|
+
if config['phase'] && !config['phase'].empty?
|
544
|
+
yaml_config['phase'] = config['phase']
|
545
|
+
end
|
518
546
|
|
519
|
-
|
520
|
-
|
521
|
-
phase_content += " command: #{config['phase']['implement']['command']}\n"
|
522
|
-
if config['phase']['implement']['options'].present?
|
523
|
-
phase_content += " options:\n"
|
524
|
-
config['phase']['implement']['options'].each do |opt|
|
525
|
-
phase_content += " - #{opt}\n"
|
526
|
-
end
|
527
|
-
end
|
528
|
-
if config['phase']['implement']['parameter']
|
529
|
-
phase_content += " parameter: '#{config['phase']['implement']['parameter']}'\n"
|
530
|
-
end
|
531
|
-
end
|
547
|
+
# Generate YAML with comments
|
548
|
+
yaml_output = generate_yaml_with_comments(yaml_config)
|
532
549
|
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
550
|
+
File.write(config_path, yaml_output)
|
551
|
+
end
|
552
|
+
|
553
|
+
def generate_yaml_with_comments(config)
|
554
|
+
output = []
|
555
|
+
output << "# soba CLI configuration"
|
556
|
+
output << "# Generated by: soba init"
|
557
|
+
output << "# Date: #{Time.now}"
|
558
|
+
output << ""
|
559
|
+
|
560
|
+
# GitHub section
|
561
|
+
output << "github:"
|
562
|
+
if config['github']['auth_method']
|
563
|
+
output << " # Authentication method (gh, env, or null)"
|
564
|
+
output << " auth_method: #{config['github']['auth_method']}"
|
565
|
+
end
|
566
|
+
|
567
|
+
if config['github']['token']
|
568
|
+
output << " # GitHub Personal Access Token"
|
569
|
+
output << " # Can use environment variable: ${GITHUB_TOKEN}"
|
570
|
+
output << " token: #{config['github']['token']}"
|
571
|
+
end
|
546
572
|
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
573
|
+
output << " # Target repository (format: owner/repo)"
|
574
|
+
output << " repository: #{config['github']['repository']}"
|
575
|
+
output << ""
|
576
|
+
|
577
|
+
# Workflow section
|
578
|
+
output << "workflow:"
|
579
|
+
output << " # Issue polling interval in seconds"
|
580
|
+
output << " interval: #{config['workflow']['interval']}"
|
581
|
+
output << ""
|
582
|
+
output << " # Enable automatic merging of PRs with soba:lgtm label"
|
583
|
+
output << " auto_merge_enabled: #{config['workflow']['auto_merge_enabled']}"
|
584
|
+
output << ""
|
585
|
+
output << " # Enable automatic cleanup of tmux windows for closed issues"
|
586
|
+
output << " closed_issue_cleanup_enabled: #{config['workflow']['closed_issue_cleanup_enabled']}"
|
587
|
+
output << ""
|
588
|
+
output << " # Cleanup check interval in seconds"
|
589
|
+
output << " closed_issue_cleanup_interval: #{config['workflow']['closed_issue_cleanup_interval']}"
|
590
|
+
output << ""
|
591
|
+
output << " # Delay (in seconds) before sending commands to new tmux panes/windows"
|
592
|
+
output << " tmux_command_delay: #{config['workflow']['tmux_command_delay']}"
|
593
|
+
output << ""
|
594
|
+
output << " # Phase labels for tracking issue progress"
|
595
|
+
output << " phase_labels:"
|
596
|
+
config['workflow']['phase_labels'].each do |key, value|
|
597
|
+
output << " #{key}: #{value}"
|
598
|
+
end
|
599
|
+
output << ""
|
600
|
+
|
601
|
+
# Slack section
|
602
|
+
output << "# Slack notification configuration"
|
603
|
+
output << "slack:"
|
604
|
+
output << " # Slack Webhook URL for notifications"
|
605
|
+
output << " # Can use environment variable: ${SLACK_WEBHOOK_URL}"
|
606
|
+
output << " webhook_url: #{config['slack']['webhook_url']}"
|
607
|
+
output << ""
|
608
|
+
output << " # Enable Slack notifications for phase starts"
|
609
|
+
output << " notifications_enabled: #{config['slack']['notifications_enabled']}"
|
610
|
+
|
611
|
+
# Phase section (if present)
|
612
|
+
if config['phase'] && !config['phase'].empty?
|
613
|
+
output << ""
|
614
|
+
output << "# Phase command configuration"
|
615
|
+
output << "phase:"
|
616
|
+
|
617
|
+
config['phase'].each do |phase_name, phase_config|
|
618
|
+
output << " #{phase_name}:"
|
619
|
+
output << " command: #{phase_config['command']}"
|
620
|
+
|
621
|
+
if phase_config['options'] && !phase_config['options'].empty?
|
622
|
+
output << " options:"
|
623
|
+
phase_config['options'].each do |opt|
|
624
|
+
output << " - #{opt}"
|
554
625
|
end
|
555
626
|
end
|
556
|
-
|
557
|
-
|
627
|
+
|
628
|
+
if phase_config['parameter']
|
629
|
+
output << " parameter: '#{phase_config['parameter']}'"
|
558
630
|
end
|
559
631
|
end
|
560
|
-
|
561
|
-
# Remove extra indentation to match YAML structure
|
562
|
-
phase_content = phase_content.gsub(/^ /, '')
|
563
|
-
config_content += phase_content
|
564
632
|
end
|
565
633
|
|
566
|
-
|
634
|
+
output.join("\n") + "\n"
|
567
635
|
end
|
568
636
|
|
569
637
|
def check_github_token(token: '${GITHUB_TOKEN}')
|
data/lib/soba/configuration.rb
CHANGED
@@ -4,6 +4,7 @@ require 'active_support/core_ext/object/blank'
|
|
4
4
|
require 'dry-configurable'
|
5
5
|
require 'yaml'
|
6
6
|
require 'pathname'
|
7
|
+
require_relative 'infrastructure/github_token_provider'
|
7
8
|
|
8
9
|
module Soba
|
9
10
|
class Configuration
|
@@ -12,6 +13,7 @@ module Soba
|
|
12
13
|
setting :github do
|
13
14
|
setting :token, default: ENV.fetch('GITHUB_TOKEN', nil)
|
14
15
|
setting :repository
|
16
|
+
setting :auth_method, default: nil # 'gh', 'env', or nil (auto-detect)
|
15
17
|
end
|
16
18
|
|
17
19
|
setting :workflow do
|
@@ -62,6 +64,7 @@ module Soba
|
|
62
64
|
configure do |c|
|
63
65
|
c.github.token = ENV.fetch('GITHUB_TOKEN', nil)
|
64
66
|
c.github.repository = nil
|
67
|
+
c.github.auth_method = nil
|
65
68
|
c.workflow.interval = 20
|
66
69
|
c.workflow.use_tmux = true
|
67
70
|
c.workflow.auto_merge_enabled = true
|
@@ -141,6 +144,7 @@ module Soba
|
|
141
144
|
if data['github']
|
142
145
|
c.github.token = data.dig('github', 'token') || ENV.fetch('GITHUB_TOKEN', nil)
|
143
146
|
c.github.repository = data.dig('github', 'repository')
|
147
|
+
c.github.auth_method = data.dig('github', 'auth_method')
|
144
148
|
end
|
145
149
|
|
146
150
|
if data['workflow']
|
@@ -160,7 +164,7 @@ module Soba
|
|
160
164
|
|
161
165
|
if data['git']
|
162
166
|
c.git.worktree_base_path = data.dig('git', 'worktree_base_path') || '.git/soba/worktrees'
|
163
|
-
c.git.setup_workspace = data.dig('git', 'setup_workspace') != false
|
167
|
+
c.git.setup_workspace = data.dig('git', 'setup_workspace') != false # default true
|
164
168
|
end
|
165
169
|
|
166
170
|
if data['phase']
|
@@ -194,7 +198,10 @@ module Soba
|
|
194
198
|
default_content = <<~YAML
|
195
199
|
# soba CLI configuration
|
196
200
|
github:
|
197
|
-
#
|
201
|
+
# Authentication method: 'gh', 'env', or omit for auto-detect
|
202
|
+
# auth_method: gh
|
203
|
+
|
204
|
+
# GitHub Personal Access Token (used when auth_method is 'env' or omitted)
|
198
205
|
# Can use environment variable: ${GITHUB_TOKEN}
|
199
206
|
token: ${GITHUB_TOKEN}
|
200
207
|
|
@@ -255,7 +262,33 @@ module Soba
|
|
255
262
|
def validate!
|
256
263
|
errors = []
|
257
264
|
|
258
|
-
|
265
|
+
# Validate auth_method if specified
|
266
|
+
if config.github.auth_method && !['gh', 'env'].include?(config.github.auth_method)
|
267
|
+
errors << "Invalid auth_method: #{config.github.auth_method}. Must be 'gh', 'env', or nil"
|
268
|
+
end
|
269
|
+
|
270
|
+
# Token validation now depends on auth_method
|
271
|
+
# Let GitHubTokenProvider handle token fetching and validation
|
272
|
+
# We only need to check if token can be obtained
|
273
|
+
begin
|
274
|
+
token_provider = Soba::Infrastructure::GitHubTokenProvider.new
|
275
|
+
if config.github.auth_method
|
276
|
+
# Try to fetch with specified method
|
277
|
+
token = token_provider.fetch(auth_method: config.github.auth_method)
|
278
|
+
# Store the fetched token if not already set
|
279
|
+
config.github.token ||= token
|
280
|
+
elsif config.github.token.blank?
|
281
|
+
# Auto-detect mode when no token is provided
|
282
|
+
token = token_provider.fetch(auth_method: nil)
|
283
|
+
config.github.token = token
|
284
|
+
end
|
285
|
+
rescue Soba::Infrastructure::GitHubTokenProvider::TokenFetchError => e
|
286
|
+
# Only add error if token is required and cannot be fetched
|
287
|
+
if config.github.token.blank?
|
288
|
+
errors << "GitHub token is not available: #{e.message}"
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
259
292
|
errors << "GitHub repository is not set" if config.github.repository.blank?
|
260
293
|
errors << "Workflow interval must be positive" if config.workflow.interval <= 0
|
261
294
|
|
@@ -5,6 +5,7 @@ require "faraday"
|
|
5
5
|
require "faraday/retry"
|
6
6
|
require "semantic_logger"
|
7
7
|
require_relative "errors"
|
8
|
+
require_relative "github_token_provider"
|
8
9
|
|
9
10
|
module Soba
|
10
11
|
module Infrastructure
|
@@ -14,8 +15,27 @@ module Soba
|
|
14
15
|
attr_reader :octokit
|
15
16
|
|
16
17
|
def initialize(token: nil)
|
17
|
-
|
18
|
-
token
|
18
|
+
# If token is explicitly provided, use it
|
19
|
+
if token.nil?
|
20
|
+
# Try to get token from configuration or token provider
|
21
|
+
if defined?(Configuration) && Configuration.respond_to?(:config) && Configuration.config
|
22
|
+
config = Configuration.config
|
23
|
+
if config.github.token.present?
|
24
|
+
token = config.github.token
|
25
|
+
elsif config.github.auth_method
|
26
|
+
# Use GitHubTokenProvider with specified auth_method
|
27
|
+
token_provider = GitHubTokenProvider.new
|
28
|
+
token = token_provider.fetch(auth_method: config.github.auth_method)
|
29
|
+
else
|
30
|
+
# Auto-detect mode
|
31
|
+
token_provider = GitHubTokenProvider.new
|
32
|
+
token = token_provider.fetch(auth_method: nil)
|
33
|
+
end
|
34
|
+
else
|
35
|
+
# Fallback to environment variable
|
36
|
+
token = ENV["GITHUB_TOKEN"]
|
37
|
+
end
|
38
|
+
end
|
19
39
|
|
20
40
|
stack = build_middleware_stack
|
21
41
|
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'English'
|
4
|
+
|
5
|
+
module Soba
|
6
|
+
module Infrastructure
|
7
|
+
# rubocop:disable Airbnb/ModuleMethodInWrongFile
|
8
|
+
class GitHubTokenProvider
|
9
|
+
class TokenFetchError < StandardError; end
|
10
|
+
|
11
|
+
def fetch(auth_method: nil)
|
12
|
+
case auth_method
|
13
|
+
when 'gh'
|
14
|
+
fetch_from_gh
|
15
|
+
when 'env'
|
16
|
+
fetch_from_env
|
17
|
+
when nil
|
18
|
+
fetch_auto
|
19
|
+
else
|
20
|
+
raise TokenFetchError, "Invalid auth_method: #{auth_method}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def gh_available?
|
25
|
+
return false unless system('which gh > /dev/null 2>&1')
|
26
|
+
|
27
|
+
output = `gh auth token 2>/dev/null`
|
28
|
+
last_command_status.success? && !output.strip.empty?
|
29
|
+
end
|
30
|
+
|
31
|
+
def detect_best_method
|
32
|
+
return 'gh' if gh_available?
|
33
|
+
return 'env' if ENV['GITHUB_TOKEN']
|
34
|
+
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def fetch_from_gh
|
41
|
+
unless system('which gh > /dev/null 2>&1')
|
42
|
+
raise TokenFetchError, 'gh command not found. Please install GitHub CLI'
|
43
|
+
end
|
44
|
+
|
45
|
+
token = `gh auth token 2>/dev/null`.strip
|
46
|
+
|
47
|
+
unless last_command_status.success?
|
48
|
+
raise TokenFetchError, 'Failed to get token from gh command. Please run `gh auth login` first'
|
49
|
+
end
|
50
|
+
|
51
|
+
if token.empty?
|
52
|
+
raise TokenFetchError, 'gh auth token returned empty. Please run `gh auth login` first'
|
53
|
+
end
|
54
|
+
|
55
|
+
token
|
56
|
+
end
|
57
|
+
|
58
|
+
def last_command_status
|
59
|
+
# Return the child process status, with nil check
|
60
|
+
status = $CHILD_STATUS || $LAST_CHILD_STATUS
|
61
|
+
|
62
|
+
# If both are nil, create a fake failed status
|
63
|
+
if status.nil?
|
64
|
+
# Create a stub status object that responds to success?
|
65
|
+
Struct.new(:success?).new(false)
|
66
|
+
else
|
67
|
+
status
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def fetch_from_env
|
72
|
+
token = ENV['GITHUB_TOKEN']
|
73
|
+
|
74
|
+
if token.nil?
|
75
|
+
raise TokenFetchError, 'GITHUB_TOKEN environment variable not set'
|
76
|
+
end
|
77
|
+
|
78
|
+
if token.empty?
|
79
|
+
raise TokenFetchError, 'GITHUB_TOKEN environment variable is empty'
|
80
|
+
end
|
81
|
+
|
82
|
+
token
|
83
|
+
end
|
84
|
+
|
85
|
+
def fetch_auto
|
86
|
+
if gh_available?
|
87
|
+
fetch_from_gh
|
88
|
+
elsif ENV['GITHUB_TOKEN']
|
89
|
+
fetch_from_env
|
90
|
+
else
|
91
|
+
raise TokenFetchError,
|
92
|
+
'No GitHub token available. Please set GITHUB_TOKEN environment variable or run `gh auth login`'
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
# rubocop:enable Airbnb/ModuleMethodInWrongFile
|
97
|
+
end
|
98
|
+
end
|
@@ -3,6 +3,8 @@
|
|
3
3
|
require "semantic_logger"
|
4
4
|
require_relative "../infrastructure/github_client"
|
5
5
|
require_relative "../configuration"
|
6
|
+
require_relative "slack_notifier"
|
7
|
+
require_relative "git_workspace_manager"
|
6
8
|
|
7
9
|
module Soba
|
8
10
|
module Services
|
@@ -12,6 +14,7 @@ module Soba
|
|
12
14
|
def initialize
|
13
15
|
@github_client = Infrastructure::GitHubClient.new
|
14
16
|
@repository = Configuration.config.github.repository
|
17
|
+
@git_workspace_manager = GitWorkspaceManager.new
|
15
18
|
end
|
16
19
|
|
17
20
|
def execute
|
@@ -41,7 +44,7 @@ module Soba
|
|
41
44
|
if check_mergeable(pr_number)
|
42
45
|
result = perform_merge(pr_number)
|
43
46
|
if result[:merged]
|
44
|
-
handle_post_merge(pr_number)
|
47
|
+
handle_post_merge(pr_number, sha: result[:sha])
|
45
48
|
merged << { number: pr_number, title: pr[:title], sha: result[:sha] }
|
46
49
|
logger.info "PR merged successfully", pr_number: pr_number, sha: result[:sha]
|
47
50
|
else
|
@@ -113,21 +116,61 @@ module Soba
|
|
113
116
|
@github_client.merge_pull_request(@repository, pr_number, merge_method: "squash")
|
114
117
|
end
|
115
118
|
|
116
|
-
def handle_post_merge(pr_number)
|
119
|
+
def handle_post_merge(pr_number, sha: nil)
|
117
120
|
logger.debug "Handling post-merge actions", pr_number: pr_number
|
118
121
|
|
122
|
+
# Update main branch after successful merge
|
123
|
+
begin
|
124
|
+
logger.info "Updating main branch after merge", pr_number: pr_number
|
125
|
+
@git_workspace_manager.update_main_branch
|
126
|
+
logger.info "Main branch updated successfully"
|
127
|
+
rescue GitWorkspaceManager::GitOperationError => e
|
128
|
+
logger.error "Failed to update main branch", error: e.message
|
129
|
+
rescue => e
|
130
|
+
logger.error "Unexpected error updating main branch", error: e.message
|
131
|
+
end
|
132
|
+
|
119
133
|
# Extract issue number from PR body
|
120
134
|
issue_number = @github_client.get_pr_issue_number(@repository, pr_number)
|
121
135
|
|
122
136
|
if issue_number
|
123
137
|
logger.info "Closing related issue", pr_number: pr_number, issue_number: issue_number
|
124
138
|
@github_client.close_issue_with_label(@repository, issue_number, label: "soba:merged")
|
139
|
+
|
140
|
+
# Send Slack notification for merged issue
|
141
|
+
send_merge_notification(issue_number, pr_number, sha)
|
125
142
|
else
|
126
143
|
logger.warn "No related issue found in PR body", pr_number: pr_number
|
127
144
|
end
|
128
145
|
rescue => e
|
129
146
|
logger.error "Failed to handle post-merge actions", pr_number: pr_number, error: e.message
|
130
147
|
end
|
148
|
+
|
149
|
+
def send_merge_notification(issue_number, pr_number, sha)
|
150
|
+
slack_notifier = SlackNotifier.from_config
|
151
|
+
return unless slack_notifier&.enabled?
|
152
|
+
|
153
|
+
begin
|
154
|
+
pr_data = @github_client.get_pull_request(@repository, pr_number)
|
155
|
+
issue_data = @github_client.issue(@repository, issue_number)
|
156
|
+
|
157
|
+
merge_data = {
|
158
|
+
issue_number: issue_number,
|
159
|
+
issue_title: issue_data[:title],
|
160
|
+
pr_number: pr_number,
|
161
|
+
pr_title: pr_data[:title],
|
162
|
+
sha: sha,
|
163
|
+
repository: @repository,
|
164
|
+
}
|
165
|
+
|
166
|
+
slack_notifier.notify_issue_merged(merge_data)
|
167
|
+
logger.debug "Slack notification sent for merged issue", issue_number: issue_number
|
168
|
+
rescue => e
|
169
|
+
logger.warn "Failed to send Slack notification for merged issue",
|
170
|
+
issue_number: issue_number,
|
171
|
+
error: e.message
|
172
|
+
end
|
173
|
+
end
|
131
174
|
end
|
132
175
|
end
|
133
176
|
end
|
@@ -34,6 +34,30 @@ module Soba
|
|
34
34
|
end
|
35
35
|
end
|
36
36
|
|
37
|
+
def notify_issue_merged(merge_data)
|
38
|
+
return false unless enabled?
|
39
|
+
|
40
|
+
logger.debug "Starting Slack notification for merged issue ##{merge_data[:issue_number]}"
|
41
|
+
|
42
|
+
begin
|
43
|
+
message = build_merged_message(merge_data)
|
44
|
+
logger.debug "Sending notification to Slack webhook"
|
45
|
+
|
46
|
+
response = send_notification(message)
|
47
|
+
|
48
|
+
if response.success?
|
49
|
+
logger.debug "Slack notification sent successfully (HTTP #{response.status})"
|
50
|
+
true
|
51
|
+
else
|
52
|
+
logger.warn("Failed to send Slack notification: HTTP #{response.status}")
|
53
|
+
false
|
54
|
+
end
|
55
|
+
rescue StandardError => e
|
56
|
+
logger.warn("Error sending Slack notification: #{e.message}")
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
37
61
|
def enabled?
|
38
62
|
@webhook_url.present?
|
39
63
|
end
|
@@ -109,6 +133,60 @@ module Soba
|
|
109
133
|
}
|
110
134
|
end
|
111
135
|
|
136
|
+
def build_merged_message(merge_data)
|
137
|
+
issue_url = if merge_data[:repository]
|
138
|
+
"https://github.com/#{merge_data[:repository]}/issues/#{merge_data[:issue_number]}"
|
139
|
+
else
|
140
|
+
"##{merge_data[:issue_number]}"
|
141
|
+
end
|
142
|
+
|
143
|
+
issue_value = if merge_data[:repository]
|
144
|
+
"<#{issue_url}|##{merge_data[:issue_number]}>"
|
145
|
+
else
|
146
|
+
"##{merge_data[:issue_number]}"
|
147
|
+
end
|
148
|
+
|
149
|
+
fields = [
|
150
|
+
{
|
151
|
+
title: "Issue",
|
152
|
+
value: issue_value,
|
153
|
+
short: true,
|
154
|
+
},
|
155
|
+
]
|
156
|
+
|
157
|
+
if merge_data[:pr_number] && merge_data[:repository]
|
158
|
+
pr_url = "https://github.com/#{merge_data[:repository]}/pull/#{merge_data[:pr_number]}"
|
159
|
+
pr_value = "<#{pr_url}|##{merge_data[:pr_number]}>"
|
160
|
+
fields << {
|
161
|
+
title: "PR",
|
162
|
+
value: pr_value,
|
163
|
+
short: true,
|
164
|
+
}
|
165
|
+
end
|
166
|
+
|
167
|
+
if merge_data[:sha]
|
168
|
+
fields << {
|
169
|
+
title: "SHA",
|
170
|
+
value: merge_data[:sha],
|
171
|
+
short: true,
|
172
|
+
}
|
173
|
+
end
|
174
|
+
|
175
|
+
{
|
176
|
+
text: "✅ Soba merged: Issue ##{merge_data[:issue_number]}",
|
177
|
+
attachments: [
|
178
|
+
{
|
179
|
+
color: "good",
|
180
|
+
title: merge_data[:issue_title],
|
181
|
+
fields: fields,
|
182
|
+
footer: "Soba CLI",
|
183
|
+
footer_icon: "https://github.com/favicon.ico",
|
184
|
+
ts: Time.now.to_i,
|
185
|
+
},
|
186
|
+
],
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
112
190
|
def logger
|
113
191
|
@logger ||= if defined?(Soba.logger)
|
114
192
|
Soba.logger
|
data/lib/soba/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: soba-cli
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- douhashi
|
@@ -371,6 +371,7 @@ files:
|
|
371
371
|
- lib/soba/domain/phase_strategy.rb
|
372
372
|
- lib/soba/infrastructure/errors.rb
|
373
373
|
- lib/soba/infrastructure/github_client.rb
|
374
|
+
- lib/soba/infrastructure/github_token_provider.rb
|
374
375
|
- lib/soba/infrastructure/lock_manager.rb
|
375
376
|
- lib/soba/infrastructure/tmux_client.rb
|
376
377
|
- lib/soba/services/ansi_processor.rb
|