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.
- checksums.yaml +7 -0
- data/README.md +275 -0
- data/bin/wralph +60 -0
- data/lib/wralph/adapters/agents/claude_code.rb +19 -0
- data/lib/wralph/adapters/agents.rb +9 -0
- data/lib/wralph/adapters/cis/circle_ci.rb +199 -0
- data/lib/wralph/adapters/cis.rb +9 -0
- data/lib/wralph/adapters/objective_repositories/github_issues.rb +37 -0
- data/lib/wralph/adapters/objective_repositories.rb +9 -0
- data/lib/wralph/config.rb +89 -0
- data/lib/wralph/fixtures/config.yaml +18 -0
- data/lib/wralph/fixtures/secrets.yaml +5 -0
- data/lib/wralph/interfaces/agent.rb +13 -0
- data/lib/wralph/interfaces/ci.rb +108 -0
- data/lib/wralph/interfaces/objective_repository.rb +81 -0
- data/lib/wralph/interfaces/print.rb +32 -0
- data/lib/wralph/interfaces/repo.rb +75 -0
- data/lib/wralph/interfaces/shell.rb +60 -0
- data/lib/wralph/run/execute_plan.rb +185 -0
- data/lib/wralph/run/feedback.rb +116 -0
- data/lib/wralph/run/init.rb +98 -0
- data/lib/wralph/run/iterate_ci.rb +120 -0
- data/lib/wralph/run/plan.rb +107 -0
- data/lib/wralph/run/remove.rb +43 -0
- data/lib/wralph/version.rb +5 -0
- data/lib/wralph.rb +19 -0
- metadata +123 -0
|
@@ -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,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
|