wralph 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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'ostruct'
5
+ require_relative 'interfaces/repo'
6
+ require_relative 'interfaces/print'
7
+
8
+ module Wralph
9
+ class Config
10
+ class << self
11
+ def load
12
+ @config ||= begin
13
+ config_file = Interfaces::Repo.config_file
14
+ unless File.exist?(config_file)
15
+ Interfaces::Print.warning "Config file #{config_file} not found, using default settings"
16
+ return default_config
17
+ end
18
+
19
+ begin
20
+ yaml_data = YAML.load_file(config_file)
21
+ yaml_data = {} if yaml_data.nil? || yaml_data == false
22
+ # Merge with defaults to ensure all keys exist
23
+ merged_data = default_hash.merge(yaml_data) do |_key, default_val, yaml_val|
24
+ if default_val.is_a?(Hash) && yaml_val.is_a?(Hash)
25
+ default_val.merge(yaml_val)
26
+ else
27
+ yaml_val
28
+ end
29
+ end
30
+ deep_struct(merged_data)
31
+ rescue Psych::SyntaxError => e
32
+ Interfaces::Print.warning "Failed to parse #{config_file}: #{e.message}"
33
+ default_config
34
+ end
35
+ end
36
+
37
+ @config
38
+ end
39
+
40
+ def reload
41
+ @config = nil
42
+ load
43
+ end
44
+
45
+ def reset
46
+ @config = nil
47
+ end
48
+
49
+ def method_missing(method_name, *args, &block)
50
+ if load.respond_to?(method_name)
51
+ load.public_send(method_name, *args, &block)
52
+ else
53
+ super
54
+ end
55
+ end
56
+
57
+ def respond_to_missing?(method_name, include_private = false)
58
+ load.respond_to?(method_name, include_private) || super
59
+ end
60
+
61
+ private
62
+
63
+ def default_hash
64
+ {
65
+ 'objective_repository' => {
66
+ 'source' => 'github_issues'
67
+ },
68
+ 'ci' => {
69
+ 'source' => 'circle_ci'
70
+ }
71
+ }
72
+ end
73
+
74
+ def default_config
75
+ deep_struct(default_hash)
76
+ end
77
+
78
+ def deep_struct(hash)
79
+ return hash unless hash.is_a?(Hash)
80
+
81
+ OpenStruct.new(
82
+ hash.transform_values do |value|
83
+ value.is_a?(Hash) ? deep_struct(value) : value
84
+ end
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,18 @@
1
+ # WRALPH Configuration
2
+ # Configure which adapters to use for different components
3
+
4
+ # Where the objectives, PRDs, or other issue write-ups are stored.
5
+ objective_repository:
6
+ # You can specify a "custom" source, just provide a class that implements
7
+ # the ObjectiveRepository interface and specify the class_name below. The
8
+ # file containing the class must be in the .wralph directory and track the
9
+ # class name in snake_case, e.g.: my_custom_objective_repository.rb
10
+ source: github_issues
11
+
12
+ # CI/CD system adapter configuration
13
+ ci:
14
+ # You can specify a "custom" source, just provide a class that implements
15
+ # the CI interface and specify the class_name below. The file containing
16
+ # the class must be in the .wralph directory and track the class name in
17
+ # snake_case, e.g.: my_custom_ci_adapter.rb
18
+ source: circle_ci
@@ -0,0 +1,5 @@
1
+ # WRALPH Secrets Configuration
2
+ # Add your CI API tokens and other secrets here
3
+ # This file is git-ignored for security
4
+
5
+ ci_api_token: # Add your CircleCI API token here
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../adapters/agents'
4
+
5
+ module Wralph
6
+ module Interfaces
7
+ module Agent
8
+ def self.run(instructions)
9
+ Adapters::Agents::ClaudeCode.run(instructions)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative '../adapters/cis'
5
+ require_relative '../config'
6
+ require_relative 'repo'
7
+
8
+ module Wralph
9
+ module Interfaces
10
+ module Ci
11
+ def self.build_status(pr_number, repo_owner, repo_name, api_token, verbose: true)
12
+ adapter.build_status(pr_number, repo_owner, repo_name, api_token, verbose: verbose)
13
+ end
14
+
15
+ def self.wait_for_build(pr_number, repo_owner, repo_name, api_token)
16
+ adapter.wait_for_build(pr_number, repo_owner, repo_name, api_token)
17
+ end
18
+
19
+ def self.build_failures(pr_number, repo_owner, repo_name, api_token)
20
+ adapter.build_failures(pr_number, repo_owner, repo_name, api_token)
21
+ end
22
+
23
+ def self.api_token
24
+ secrets = load_secrets
25
+ token = secrets['ci_api_token']
26
+ return nil unless token
27
+
28
+ token = token.strip
29
+ token.empty? ? nil : token
30
+ end
31
+
32
+ def self.reset_adapter
33
+ @adapter = nil
34
+ end
35
+
36
+ def self.adapter
37
+ @adapter ||= load_adapter
38
+ end
39
+
40
+ def self.load_adapter
41
+ config = Config.load
42
+ source = config.ci.source
43
+
44
+ case source
45
+ when 'circle_ci'
46
+ Adapters::Cis::CircleCi
47
+ when 'custom'
48
+ load_custom_adapter(config.ci.class_name)
49
+ else
50
+ raise "Unknown CI source: #{source}"
51
+ end
52
+ end
53
+
54
+ def self.load_custom_adapter(class_name)
55
+ raise "class_name is required when source is 'custom'" if class_name.nil? || class_name.empty?
56
+
57
+ # Convert class name to snake_case file path
58
+ file_name = class_name_to_snake_case(class_name)
59
+ adapter_file = File.join(Repo.wralph_dir, "#{file_name}.rb")
60
+
61
+ raise "Custom adapter file not found: #{adapter_file}" unless File.exist?(adapter_file)
62
+
63
+ # Load the file
64
+ load adapter_file
65
+
66
+ # Get the class constant
67
+ begin
68
+ klass = Object.const_get(class_name)
69
+ validate_adapter_interface(klass)
70
+ klass
71
+ rescue NameError => e
72
+ raise "Failed to load custom adapter class '#{class_name}': #{e.message}"
73
+ end
74
+ end
75
+
76
+ def self.class_name_to_snake_case(class_name)
77
+ # Convert CamelCase to snake_case
78
+ # Insert underscore before capital letters (except the first one)
79
+ # Then downcase everything
80
+ class_name
81
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
82
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
83
+ .downcase
84
+ end
85
+
86
+ def self.validate_adapter_interface(klass)
87
+ required_methods = %i[build_status wait_for_build build_failures]
88
+ missing_methods = required_methods.reject { |method| klass.respond_to?(method) }
89
+
90
+ return if missing_methods.empty?
91
+
92
+ raise "Custom adapter class must implement: #{missing_methods.join(', ')}"
93
+ end
94
+
95
+ def self.load_secrets
96
+ secrets_file = Repo.secrets_file
97
+ return {} unless File.exist?(secrets_file)
98
+
99
+ begin
100
+ YAML.load_file(secrets_file) || {}
101
+ rescue Psych::SyntaxError => e
102
+ Print.warning "Failed to parse #{secrets_file}: #{e.message}"
103
+ {}
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../adapters/objective_repositories'
4
+ require_relative '../config'
5
+
6
+ module Wralph
7
+ module Interfaces
8
+ module ObjectiveRepository
9
+ def self.download!(identifier)
10
+ adapter.download!(identifier)
11
+ end
12
+
13
+ def self.local_file_path(identifier)
14
+ adapter.local_file_path(identifier)
15
+ end
16
+
17
+ def self.reset_adapter
18
+ @adapter = nil
19
+ end
20
+
21
+ def self.adapter
22
+ @adapter ||= load_adapter
23
+ end
24
+
25
+ def self.load_adapter
26
+ config = Config.load
27
+ source = config.objective_repository.source
28
+
29
+ case source
30
+ when 'github_issues'
31
+ Adapters::ObjectiveRepositories::GithubIssues
32
+ when 'custom'
33
+ load_custom_adapter(config.objective_repository.class_name)
34
+ else
35
+ raise "Unknown objective_repository source: #{source}"
36
+ end
37
+ end
38
+
39
+ def self.load_custom_adapter(class_name)
40
+ raise "class_name is required when source is 'custom'" if class_name.nil? || class_name.empty?
41
+
42
+ # Convert class name to snake_case file path
43
+ file_name = class_name_to_snake_case(class_name)
44
+ adapter_file = File.join(Repo.wralph_dir, "#{file_name}.rb")
45
+
46
+ raise "Custom adapter file not found: #{adapter_file}" unless File.exist?(adapter_file)
47
+
48
+ # Load the file
49
+ load adapter_file
50
+
51
+ # Get the class constant
52
+ begin
53
+ klass = Object.const_get(class_name)
54
+ validate_adapter_interface(klass)
55
+ klass
56
+ rescue NameError => e
57
+ raise "Failed to load custom adapter class '#{class_name}': #{e.message}"
58
+ end
59
+ end
60
+
61
+ def self.class_name_to_snake_case(class_name)
62
+ # Convert CamelCase to snake_case
63
+ # Insert underscore before capital letters (except the first one)
64
+ # Then downcase everything
65
+ class_name
66
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
67
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
68
+ .downcase
69
+ end
70
+
71
+ def self.validate_adapter_interface(klass)
72
+ required_methods = %i[download! local_file_path]
73
+ missing_methods = required_methods.reject { |method| klass.respond_to?(method) }
74
+
75
+ return if missing_methods.empty?
76
+
77
+ raise "Custom adapter class must implement: #{missing_methods.join(', ')}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wralph
4
+ module Interfaces
5
+ module Print
6
+ # Colors for output
7
+ module Colors
8
+ RED = "\033[0;31m"
9
+ GREEN = "\033[0;32m"
10
+ YELLOW = "\033[1;33m"
11
+ BLUE = "\033[0;34m"
12
+ NC = "\033[0m" # No Color
13
+ end
14
+
15
+ def self.info(msg)
16
+ puts "#{Colors::BLUE}ℹ#{Colors::NC} #{msg}"
17
+ end
18
+
19
+ def self.success(msg)
20
+ puts "#{Colors::GREEN}✓#{Colors::NC} #{msg}"
21
+ end
22
+
23
+ def self.warning(msg)
24
+ puts "#{Colors::YELLOW}⚠#{Colors::NC} #{msg}"
25
+ end
26
+
27
+ def self.error(msg)
28
+ puts "#{Colors::RED}✗#{Colors::NC} #{msg}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wralph
4
+ module Interfaces
5
+ module Repo
6
+ def self.install_dir
7
+ @install_dir ||= begin
8
+ # If we're running from source (lib/wralph/interfaces/repo.rb exists relative to bin/)
9
+ script_path = File.realpath($0) if $0
10
+ if script_path && File.exist?(script_path)
11
+ # Go up from bin/wralph to repo root
12
+ File.expand_path(File.join(File.dirname(script_path), '..'))
13
+ else
14
+ # Fallback: use __dir__ from this file, go up to repo root
15
+ File.expand_path(File.join(__dir__, '..', '..', '..'))
16
+ end
17
+ end
18
+ end
19
+
20
+ def self.repo_root
21
+ # Find git repository root from current working directory
22
+ dir = Dir.pwd
23
+ loop do
24
+ return dir if File.directory?(File.join(dir, '.git'))
25
+
26
+ parent = File.dirname(dir)
27
+ break if parent == dir
28
+
29
+ dir = parent
30
+ end
31
+ Dir.pwd # Fallback to current directory
32
+ end
33
+
34
+ def self.wralph_dir
35
+ File.join(repo_root, '.wralph')
36
+ end
37
+
38
+ def self.plans_dir
39
+ File.join(wralph_dir, 'plans')
40
+ end
41
+
42
+ def self.tmp_dir
43
+ File.join(repo_root, 'tmp')
44
+ end
45
+
46
+ def self.plan_file(issue_number)
47
+ File.join(plans_dir, "plan_#{issue_number}.md")
48
+ end
49
+
50
+ def self.env_file
51
+ File.join(repo_root, '.env')
52
+ end
53
+
54
+ def self.secrets_file
55
+ File.join(wralph_dir, 'secrets.yaml')
56
+ end
57
+
58
+ def self.config_file
59
+ File.join(wralph_dir, 'config.yaml')
60
+ end
61
+
62
+ def self.failure_details_file(branch_name, retry_count)
63
+ File.join(tmp_dir, "#{branch_name}_failure_details_#{retry_count}_#{retry_count}.txt")
64
+ end
65
+
66
+ def self.fixtures_dir
67
+ File.join(__dir__, '..', 'fixtures')
68
+ end
69
+
70
+ def self.fixture_file(filename)
71
+ File.join(fixtures_dir, filename)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require_relative 'print'
6
+
7
+ module Wralph
8
+ module Interfaces
9
+ module Shell
10
+ # Check if a command exists
11
+ def self.command_exists?(cmd)
12
+ system("which #{cmd} > /dev/null 2>&1")
13
+ end
14
+
15
+ # Run a command and return stdout, stderr, and status
16
+ def self.run_command(cmd, raise_on_error: false)
17
+ stdout, stderr, status = Open3.capture3(cmd)
18
+ raise "Command failed: #{cmd}\n#{stderr}" if raise_on_error && !status.success?
19
+
20
+ [stdout.chomp, stderr.chomp, status.success?]
21
+ end
22
+
23
+ def self.get_worktrees
24
+ json_output, = run_command("wt list --format=json")
25
+ JSON.parse(json_output.force_encoding('UTF-8'))
26
+ end
27
+
28
+ def self.switch_into_worktree(branch_name, create_if_not_exists: true)
29
+ # Check if any entry matches the branch name
30
+ worktree = get_worktrees.find { |wt| wt['branch'] == branch_name }
31
+ if worktree
32
+ success = system("wt switch #{worktree['branch']}")
33
+ unless success
34
+ Print.error "Failed to switch to branch #{branch_name}"
35
+ exit 1
36
+ end
37
+ elsif create_if_not_exists
38
+ success = system("wt switch --create #{branch_name}")
39
+ unless success
40
+ Print.error "Failed to switch to branch #{branch_name}"
41
+ exit 1
42
+ end
43
+ worktree = get_worktrees.find { |wt| wt['branch'] == branch_name }
44
+ else
45
+ Print.error "Worktree for branch #{branch_name} not found. Use --create to create it."
46
+ exit 1
47
+ end
48
+
49
+ # Change the directory of the CURRENT Ruby process to the new worktree
50
+ Dir.chdir(worktree['path'])
51
+ end
52
+
53
+ def self.ask_user_to_continue(message = 'Continue? (y/N) ')
54
+ print message
55
+ response = $stdin.gets.chomp
56
+ exit 1 unless response =~ /^[Yy]$/
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../interfaces/repo'
4
+ require_relative '../interfaces/print'
5
+ require_relative '../interfaces/shell'
6
+ require_relative '../interfaces/agent'
7
+ require_relative '../interfaces/objective_repository'
8
+ require_relative 'iterate_ci'
9
+
10
+ module Wralph
11
+ module Run
12
+ module ExecutePlan
13
+ def self.run(issue_number)
14
+ plan_file = Interfaces::Repo.plan_file(issue_number)
15
+ plan_exists = File.exist?(plan_file)
16
+
17
+ branch_name = "issue-#{issue_number}".freeze
18
+ stdout, = Interfaces::Shell.run_command('git branch --show-current')
19
+ current_branch = stdout.strip
20
+
21
+ # Check if worktree already exists, if not, create it and copy .wralph directory
22
+ worktrees = Interfaces::Shell.get_worktrees
23
+ worktree_exists = worktrees.any? { |wt| wt['branch'] == branch_name }
24
+
25
+ if !worktree_exists
26
+ # Get the main repo root before creating worktree (in case we're already in a worktree)
27
+ # Use git rev-parse to reliably get the main repo root even from within a worktree
28
+ stdout, _, success = Interfaces::Shell.run_command('git rev-parse --show-toplevel')
29
+ main_repo_root = success && !stdout.empty? ? stdout.strip : Interfaces::Repo.repo_root
30
+ main_wralph_dir = File.join(main_repo_root, '.wralph')
31
+
32
+ # Create the worktree
33
+ Interfaces::Shell.switch_into_worktree(branch_name)
34
+
35
+ # Copy entire .wralph directory from main repo to the new worktree
36
+ if Dir.exist?(main_wralph_dir)
37
+ require 'fileutils'
38
+ worktree_wralph_dir = Interfaces::Repo.wralph_dir
39
+
40
+ # Only copy if source and destination are different (should always be true in real usage)
41
+ main_wralph_expanded = File.expand_path(main_wralph_dir)
42
+ worktree_wralph_expanded = File.expand_path(worktree_wralph_dir)
43
+
44
+ if main_wralph_expanded != worktree_wralph_expanded
45
+ FileUtils.mkdir_p(worktree_wralph_dir)
46
+ # Copy all contents from main .wralph to worktree .wralph
47
+ Dir.glob(File.join(main_wralph_dir, '*'), File::FNM_DOTMATCH).each do |item|
48
+ next if ['.', '..'].include?(File.basename(item))
49
+
50
+ dest_item = File.join(worktree_wralph_dir, File.basename(item))
51
+ # Skip if source and destination are the same (can happen in tests)
52
+ item_expanded = File.expand_path(item)
53
+ dest_item_expanded = File.expand_path(dest_item)
54
+ next if item_expanded == dest_item_expanded
55
+ # Skip if destination is inside source (would cause recursive copy error)
56
+ next if dest_item_expanded.start_with?(item_expanded + File::SEPARATOR)
57
+
58
+ begin
59
+ FileUtils.cp_r(item, dest_item)
60
+ rescue ArgumentError => e
61
+ # Handle edge case where source and dest are the same (can happen in tests)
62
+ next if e.message.include?('cannot copy') && e.message.include?('to itself')
63
+
64
+ raise
65
+ end
66
+ end
67
+ end
68
+ end
69
+ else
70
+ Interfaces::Shell.switch_into_worktree(branch_name)
71
+ end
72
+
73
+ # Build execution instructions based on whether plan exists
74
+ if plan_exists
75
+ execution_instructions = <<~EXECUTION_INSTRUCTIONS
76
+ You previously created a plan to solve GitHub issue ##{issue_number}. You can find the plan in the file: `#{plan_file}`. You have been placed in a git worktree for the branch `#{branch_name}`.
77
+
78
+ Do as follows:
79
+
80
+ 1. Execute your plan:
81
+ - Make the necessary changes to solve the issue
82
+ - Commit your changes (including the plan) with a descriptive message that references the issue
83
+ - Push the branch to GitHub
84
+ - Create a pull request, referencing the issue in the body like "Fixes ##{issue_number}"
85
+
86
+ 2. After creating the PR, output the PR number and its URL so I can track it.
87
+
88
+ Please proceed with these steps.
89
+ EXECUTION_INSTRUCTIONS
90
+ Interfaces::Print.info "Running Claude Code to execute the plan #{plan_file}..."
91
+ else
92
+ # Fetch GitHub issue content
93
+ Interfaces::Print.info "No plan found. Fetching GitHub issue ##{issue_number}..."
94
+ begin
95
+ objective_file = Interfaces::ObjectiveRepository.download!(issue_number)
96
+ File.read(objective_file)
97
+ rescue StandardError => e
98
+ Interfaces::Print.error "Failed to fetch objective ##{issue_number}: #{e.message}"
99
+ exit 1
100
+ end
101
+
102
+ execution_instructions = <<~EXECUTION_INSTRUCTIONS
103
+ I need you to solve the objective "#{issue_number}" in the file: `#{objective_file}`. You have been placed in a git worktree for the branch `#{branch_name}`.
104
+
105
+ Do as follows:
106
+
107
+ 1. Solve the objective:
108
+ - Read and understand the objective's requirements
109
+ - Make the necessary code changes to solve the objective
110
+ - Write tests to verify the solution
111
+ - Commit your changes with a descriptive message that references the issue (e.g., "Fixes ##{issue_number}")
112
+ - Push the branch to GitHub
113
+ - Create a pull request, referencing the issue in the body like "Fixes ##{issue_number}"
114
+
115
+ 2. After creating the PR, output the PR number and its URL so I can track it.
116
+
117
+ Please proceed with these steps.
118
+ EXECUTION_INSTRUCTIONS
119
+ Interfaces::Print.info "Running Claude Code to solve GitHub issue ##{issue_number}..."
120
+ end
121
+
122
+ # Run claude code with instructions
123
+ claude_output = Interfaces::Agent.run(execution_instructions)
124
+ puts "CLAUDE_OUTPUT: #{claude_output}"
125
+
126
+ # Extract PR number from output (look for patterns like "PR #123", "**PR #123**", or "Pull Request #123")
127
+ # Try multiple patterns in order of specificity to avoid false matches
128
+
129
+ # Pattern 1: Look for PR in URL format (most reliable and unambiguous)
130
+ # Matches: https://github.com/owner/repo/pull/774
131
+ Interfaces::Print.info 'Extracting PR number from output by looking for the PR URL pattern...'
132
+ pr_number = claude_output.match(%r{github\.com/[^/\s]+/[^/\s]+/pull/(\d+)}i)&.[](1)
133
+
134
+ # Pattern 2: Look for "PR Number:" followed by optional markdown formatting and the number
135
+ # This handles formats like "PR Number: **#774**" or "PR Number: #774"
136
+ if pr_number.nil?
137
+ # Match "PR Number:" followed by optional whitespace, optional markdown bold, optional #, then digits
138
+ Interfaces::Print.warning 'Extracting PR number from output by looking for the PR Number pattern...'
139
+ pr_number = claude_output.match(/PR\s+Number\s*:\s*(?:\*\*)?#?(\d+)/i)&.[](1)
140
+ end
141
+
142
+ # Pattern 3: Look for "PR #" or "Pull Request #" at start of line or after heading markers
143
+ if pr_number.nil?
144
+ Interfaces::Print.warning 'Extracting PR number from output by looking for the PR # pattern...'
145
+ pr_number = claude_output.match(/(?:^|\n|###\s+)[^\n]*(?:PR|Pull Request)[:\s]+(?:\*\*)?#?(\d+)/i)&.[](1)
146
+ end
147
+
148
+ # Pattern 4: Fallback to simple pattern but exclude "Found PR" patterns
149
+ if pr_number.nil?
150
+ # Match PR but not if preceded by "Found" or similar words
151
+ Interfaces::Print.warning 'Extracting PR number from output by looking for the PR pattern...'
152
+ pr_number = claude_output.match(/(?<!Found\s)(?:PR|Pull Request)[:\s]+(?:\*\*)?#?(\d+)/i)&.[](1)
153
+ end
154
+
155
+ # Pattern 5: Last resort - any PR pattern (but this might match false positives)
156
+ if pr_number.nil?
157
+ Interfaces::Print.warning 'Extracting PR number from output by looking for the any PR pattern...'
158
+ pr_number = claude_output.match(/(?:PR|Pull Request|pull request)[^0-9]*#?(\d+)/i)&.[](1)
159
+ end
160
+
161
+ if pr_number.nil?
162
+ # Try to find PR by branch name
163
+ Interfaces::Print.warning 'PR number not found in output, searching by branch name...'
164
+ stdout, = Interfaces::Shell.run_command("gh pr list --head #{branch_name} --json number -q '.[0].number'")
165
+ pr_number = stdout.strip
166
+ pr_number = nil if pr_number.empty? || pr_number == 'null'
167
+ end
168
+
169
+ if pr_number.nil?
170
+ Interfaces::Print.error 'Could not determine PR number. Please check the Claude Code output manually.'
171
+ Interfaces::Print.error "Output: #{claude_output}"
172
+ exit 1
173
+ end
174
+
175
+ Interfaces::Print.success "Found PR ##{pr_number}"
176
+ Interfaces::Print.info 'Proceeding to monitor CircleCI build status...'
177
+
178
+ # Step 2: Monitor CircleCI build and fix if needed
179
+ IterateCI.run(issue_number, pr_number)
180
+
181
+ Interfaces::Shell.switch_into_worktree(current_branch)
182
+ end
183
+ end
184
+ end
185
+ end