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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 326730b848e785ad796cb40ce709a490abf079b297bf569b857a4cd6dd4ecab1
4
- data.tar.gz: 971c583ce08bc3e63d078365896346ef3b57674034d75388aaac7f6014e69ac9
3
+ metadata.gz: a8267febd9095e47d80e11b731840e4b82dabd17310d2cf82599315dbf98fa40
4
+ data.tar.gz: c5cb07a9ae55bea658a6afdad842fcb8eed8c3576ed19ac8dab7ba8d5b7fd336
5
5
  SHA512:
6
- metadata.gz: 23eab0546d6b61210647cc94e721803f5f7394ec3f57dfcdf1dcffcfe5f803cb369562f553ea6ab5d61151626fa68dad687d7ca43170ef21f3379eaf1549c274
7
- data.tar.gz: 4dc56c2c3b2cdb468bef76d214b62c9ce251ae9d5f5ed5c2d485505a060df96a4efb2d69ab0f3474c9c992edc313a5c5dd4ca42528e7e1c0cc2e9e402b4c551a
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
- # Rails-specific cops that don't apply to CLI applications
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
- token: ${GITHUB_TOKEN}
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
- # Personal Access Token (can use environment variable)
106
- token: ${GITHUB_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
- token: ${GITHUB_TOKEN}
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
- # Personal Access Token(環境変数可)
106
- token: ${GITHUB_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
 
@@ -1,7 +1,11 @@
1
1
  # soba CLI configuration example
2
2
  github:
3
- # GitHub Personal Access Token
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)
@@ -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.exclude?('/')
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 token
216
+ # GitHub authentication detection
197
217
  puts ""
198
- puts "GitHub Personal Access Token (PAT) setup:"
199
- puts " 1. Use environment variable ${GITHUB_TOKEN} (recommended)"
200
- puts " 2. Enter token directly (will be visible in config file)"
201
- print "Choose option (1-2) [1]: "
202
- token_option = $stdin.gets.chomp
203
- token_option = '1' if token_option.empty?
204
-
205
- token = if token_option == '2'
206
- print "Enter GitHub token: "
207
- # Hide input for security
208
- $stdin.noecho(&:gets).chomp.tap { puts }
209
- else
210
- '${GITHUB_TOKEN}'
211
- end
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 phase configuration if provided
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
- config_content = <<~YAML
449
- # soba CLI configuration
450
- # Generated by: soba init
451
- # Date: #{Time.now}
452
-
453
- github:
454
- # GitHub Personal Access Token
455
- # Can use environment variable: ${GITHUB_TOKEN}
456
- token: #{config['github']['token']}
457
-
458
- # Target repository (format: owner/repo)
459
- repository: #{config['github']['repository']}
460
-
461
- workflow:
462
- # Issue polling interval in seconds
463
- interval: #{config['workflow']['interval']}
464
-
465
- # Enable automatic merging of PRs with soba:lgtm label
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
- phase_content = "\n # Phase command configuration\n phase:\n"
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
- if config['phase']['implement']
520
- phase_content += " implement:\n"
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
- if config['phase']['review']
534
- phase_content += " review:\n"
535
- phase_content += " command: #{config['phase']['review']['command']}\n"
536
- if config['phase']['review']['options'].present?
537
- phase_content += " options:\n"
538
- config['phase']['review']['options'].each do |opt|
539
- phase_content += " - #{opt}\n"
540
- end
541
- end
542
- if config['phase']['review']['parameter']
543
- phase_content += " parameter: '#{config['phase']['review']['parameter']}'\n"
544
- end
545
- end
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
- if config['phase']['revise']
548
- phase_content += " revise:\n"
549
- phase_content += " command: #{config['phase']['revise']['command']}\n"
550
- if config['phase']['revise']['options'].present?
551
- phase_content += " options:\n"
552
- config['phase']['revise']['options'].each do |opt|
553
- phase_content += " - #{opt}\n"
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
- if config['phase']['revise']['parameter']
557
- phase_content += " parameter: '#{config['phase']['revise']['parameter']}'\n"
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
- File.write(config_path, config_content)
634
+ output.join("\n") + "\n"
567
635
  end
568
636
 
569
637
  def check_github_token(token: '${GITHUB_TOKEN}')
@@ -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 # default true
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
- # GitHub Personal Access Token
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
- errors << "GitHub token is not set" if config.github.token.blank?
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
- token ||= Configuration.config.github.token if defined?(Configuration)
18
- token ||= ENV["GITHUB_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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Soba
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
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.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