dspy 0.27.1 → 0.27.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/lib/dspy/chain_of_thought.rb +29 -37
- data/lib/dspy/code_act.rb +2 -2
- data/lib/dspy/context.rb +87 -34
- data/lib/dspy/errors.rb +2 -0
- data/lib/dspy/lm/adapters/gemini/schema_converter.rb +37 -35
- data/lib/dspy/lm/adapters/gemini_adapter.rb +45 -21
- data/lib/dspy/lm/adapters/openai/schema_converter.rb +70 -40
- data/lib/dspy/lm/adapters/openai_adapter.rb +35 -8
- data/lib/dspy/lm/retry_handler.rb +15 -6
- data/lib/dspy/lm/strategies/gemini_structured_output_strategy.rb +21 -8
- data/lib/dspy/lm.rb +54 -11
- data/lib/dspy/memory/local_embedding_engine.rb +27 -11
- data/lib/dspy/memory/memory_manager.rb +26 -9
- data/lib/dspy/mixins/type_coercion.rb +30 -0
- data/lib/dspy/module.rb +20 -2
- data/lib/dspy/observability/observation_type.rb +65 -0
- data/lib/dspy/observability.rb +7 -0
- data/lib/dspy/predict.rb +22 -36
- data/lib/dspy/re_act.rb +5 -3
- data/lib/dspy/tools/base.rb +57 -85
- data/lib/dspy/tools/github_cli_toolset.rb +437 -0
- data/lib/dspy/tools/toolset.rb +33 -60
- data/lib/dspy/type_system/sorbet_json_schema.rb +263 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +1 -0
- metadata +5 -3
- data/lib/dspy/lm/cache_manager.rb +0 -151
data/lib/dspy/tools/base.rb
CHANGED
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
require 'sorbet-runtime'
|
4
4
|
require 'json'
|
5
|
+
require_relative '../type_system/sorbet_json_schema'
|
6
|
+
require_relative '../mixins/type_coercion'
|
5
7
|
|
6
8
|
module DSPy
|
7
9
|
module Tools
|
@@ -9,6 +11,7 @@ module DSPy
|
|
9
11
|
class Base
|
10
12
|
extend T::Sig
|
11
13
|
extend T::Helpers
|
14
|
+
include DSPy::Mixins::TypeCoercion
|
12
15
|
|
13
16
|
class << self
|
14
17
|
extend T::Sig
|
@@ -30,14 +33,14 @@ module DSPy
|
|
30
33
|
|
31
34
|
# Get the JSON schema for the call method based on its Sorbet signature
|
32
35
|
sig { returns(T::Hash[Symbol, T.untyped]) }
|
33
|
-
def
|
36
|
+
def call_schema_object
|
34
37
|
method_obj = instance_method(:call)
|
35
38
|
sig_info = T::Utils.signature_for_method(method_obj)
|
36
39
|
|
37
40
|
if sig_info.nil?
|
38
41
|
# Fallback for methods without signatures
|
39
42
|
return {
|
40
|
-
type:
|
43
|
+
type: "object",
|
41
44
|
properties: {},
|
42
45
|
required: []
|
43
46
|
}
|
@@ -50,10 +53,8 @@ module DSPy
|
|
50
53
|
sig_info.arg_types.each do |param_name, param_type|
|
51
54
|
next if param_name == :block # Skip block parameters
|
52
55
|
|
53
|
-
|
54
|
-
|
55
|
-
description: "Parameter #{param_name}"
|
56
|
-
}
|
56
|
+
schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
|
57
|
+
properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
|
57
58
|
|
58
59
|
# Check if parameter is required (not nilable)
|
59
60
|
unless param_type.class.name.include?('Union') && param_type.name.include?('NilClass')
|
@@ -65,10 +66,8 @@ module DSPy
|
|
65
66
|
sig_info.kwarg_types.each do |param_name, param_type|
|
66
67
|
next if param_name == :block # Skip block parameters
|
67
68
|
|
68
|
-
|
69
|
-
|
70
|
-
description: "Parameter #{param_name}"
|
71
|
-
}
|
69
|
+
schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
|
70
|
+
properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
|
72
71
|
|
73
72
|
# Check if parameter is required by looking at required kwarg names
|
74
73
|
if sig_info.req_kwarg_names.include?(param_name)
|
@@ -79,54 +78,25 @@ module DSPy
|
|
79
78
|
end
|
80
79
|
|
81
80
|
{
|
82
|
-
type:
|
81
|
+
type: "object",
|
83
82
|
properties: properties,
|
84
83
|
required: required
|
85
84
|
}
|
86
85
|
end
|
87
86
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
{ type: :string }
|
98
|
-
elsif raw_type == Integer
|
99
|
-
{ type: :integer }
|
100
|
-
elsif raw_type == Float
|
101
|
-
{ type: :number }
|
102
|
-
elsif raw_type == Numeric
|
103
|
-
{ type: :number }
|
104
|
-
elsif raw_type == TrueClass || raw_type == FalseClass
|
105
|
-
{ type: :boolean }
|
106
|
-
elsif raw_type == T::Boolean
|
107
|
-
{ type: :boolean }
|
108
|
-
else
|
109
|
-
{ type: :string, description: "#{raw_type} (converted to string)" }
|
110
|
-
end
|
111
|
-
elsif sorbet_type.is_a?(T::Types::Union)
|
112
|
-
# Handle nilable types
|
113
|
-
non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
|
114
|
-
if non_nil_types.length == 1
|
115
|
-
result = sorbet_type_to_json_schema(non_nil_types.first)
|
116
|
-
result[:description] = "#{result[:description] || ''} (optional)".strip
|
117
|
-
result
|
118
|
-
else
|
119
|
-
{ type: :string, description: "Union type (converted to string)" }
|
120
|
-
end
|
121
|
-
elsif sorbet_type.is_a?(T::Types::TypedArray)
|
122
|
-
{
|
123
|
-
type: :array,
|
124
|
-
items: sorbet_type_to_json_schema(sorbet_type.type)
|
87
|
+
# Get the full tool schema for LLM tools format
|
88
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
89
|
+
def call_schema
|
90
|
+
{
|
91
|
+
type: 'function',
|
92
|
+
function: {
|
93
|
+
name: 'call',
|
94
|
+
description: "Call the #{self.name} tool",
|
95
|
+
parameters: call_schema_object
|
125
96
|
}
|
126
|
-
|
127
|
-
{ type: :string, description: "#{sorbet_type} (converted to string)" }
|
128
|
-
end
|
97
|
+
}
|
129
98
|
end
|
99
|
+
|
130
100
|
end
|
131
101
|
|
132
102
|
# Instance methods that tools can use
|
@@ -143,7 +113,7 @@ module DSPy
|
|
143
113
|
# Get the JSON schema string for the tool, formatted for LLM consumption
|
144
114
|
sig { returns(String) }
|
145
115
|
def schema
|
146
|
-
schema_obj = self.class.
|
116
|
+
schema_obj = self.class.call_schema_object
|
147
117
|
tool_info = {
|
148
118
|
name: name,
|
149
119
|
description: description,
|
@@ -152,11 +122,17 @@ module DSPy
|
|
152
122
|
JSON.generate(tool_info)
|
153
123
|
end
|
154
124
|
|
125
|
+
# Get the full call schema compatible with LLM tools format
|
126
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
127
|
+
def call_schema
|
128
|
+
self.class.call_schema
|
129
|
+
end
|
130
|
+
|
155
131
|
# Dynamic call method for ReAct agent - parses JSON arguments and calls the typed method
|
156
132
|
sig { params(args_json: T.untyped).returns(T.untyped) }
|
157
133
|
def dynamic_call(args_json)
|
158
134
|
# Parse arguments based on the call schema
|
159
|
-
schema = self.class.
|
135
|
+
schema = self.class.call_schema_object
|
160
136
|
|
161
137
|
if schema[:properties].empty?
|
162
138
|
# No parameters - call without arguments
|
@@ -178,12 +154,34 @@ module DSPy
|
|
178
154
|
|
179
155
|
# Convert string keys to symbols and validate types
|
180
156
|
kwargs = {}
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
157
|
+
|
158
|
+
# Get method signature for type information
|
159
|
+
method_obj = self.class.instance_method(:call)
|
160
|
+
sig_info = T::Utils.signature_for_method(method_obj)
|
161
|
+
|
162
|
+
if sig_info
|
163
|
+
# Handle kwargs using type signature information
|
164
|
+
sig_info.kwarg_types.each do |param_name, param_type|
|
165
|
+
next if param_name == :block
|
166
|
+
|
167
|
+
key = param_name.to_s
|
168
|
+
if args.key?(key)
|
169
|
+
kwargs[param_name] = coerce_value_to_type(args[key], param_type)
|
170
|
+
elsif schema[:required].include?(key)
|
171
|
+
return "Error: Missing required parameter: #{key}"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Handle positional args if any
|
176
|
+
sig_info.arg_types.each do |param_name, param_type|
|
177
|
+
next if param_name == :block
|
178
|
+
|
179
|
+
key = param_name.to_s
|
180
|
+
if args.key?(key)
|
181
|
+
kwargs[param_name] = coerce_value_to_type(args[key], param_type)
|
182
|
+
elsif schema[:required].include?(key)
|
183
|
+
return "Error: Missing required parameter: #{key}"
|
184
|
+
end
|
187
185
|
end
|
188
186
|
end
|
189
187
|
|
@@ -195,32 +193,6 @@ module DSPy
|
|
195
193
|
|
196
194
|
# Subclasses must implement their own call method with their own signature
|
197
195
|
|
198
|
-
protected
|
199
|
-
|
200
|
-
# Convert argument to the expected type based on JSON schema
|
201
|
-
sig { params(value: T.untyped, schema: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
|
202
|
-
def convert_argument_type(value, schema)
|
203
|
-
case schema[:type]
|
204
|
-
when :integer
|
205
|
-
value.is_a?(Integer) ? value : value.to_i
|
206
|
-
when :number
|
207
|
-
# Always convert to Float for :number types to ensure compatibility with strict Float signatures
|
208
|
-
value.to_f
|
209
|
-
when :boolean
|
210
|
-
case value
|
211
|
-
when true, false
|
212
|
-
value
|
213
|
-
when "true", "1", 1
|
214
|
-
true
|
215
|
-
when "false", "0", 0
|
216
|
-
false
|
217
|
-
else
|
218
|
-
!!value
|
219
|
-
end
|
220
|
-
else
|
221
|
-
value.to_s
|
222
|
-
end
|
223
|
-
end
|
224
196
|
end
|
225
197
|
end
|
226
198
|
end
|
@@ -0,0 +1,437 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sorbet-runtime'
|
4
|
+
require 'json'
|
5
|
+
require_relative 'toolset'
|
6
|
+
|
7
|
+
module DSPy
|
8
|
+
module Tools
|
9
|
+
# Enums for GitHub CLI operations
|
10
|
+
class IssueState < T::Enum
|
11
|
+
enums do
|
12
|
+
Open = new('open')
|
13
|
+
Closed = new('closed')
|
14
|
+
All = new('all')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class PRState < T::Enum
|
19
|
+
enums do
|
20
|
+
Open = new('open')
|
21
|
+
Closed = new('closed')
|
22
|
+
Merged = new('merged')
|
23
|
+
All = new('all')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class ReviewState < T::Enum
|
28
|
+
enums do
|
29
|
+
Approve = new('approve')
|
30
|
+
Comment = new('comment')
|
31
|
+
RequestChanges = new('request-changes')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Structs for complex return types
|
36
|
+
class IssueDetails < T::Struct
|
37
|
+
prop :number, Integer
|
38
|
+
prop :title, String
|
39
|
+
prop :state, String
|
40
|
+
prop :body, String
|
41
|
+
prop :url, String
|
42
|
+
prop :labels, T::Array[String]
|
43
|
+
prop :assignees, T::Array[String]
|
44
|
+
end
|
45
|
+
|
46
|
+
class PRDetails < T::Struct
|
47
|
+
prop :number, Integer
|
48
|
+
prop :title, String
|
49
|
+
prop :state, String
|
50
|
+
prop :body, String
|
51
|
+
prop :url, String
|
52
|
+
prop :base, String
|
53
|
+
prop :head, String
|
54
|
+
prop :mergeable, T::Boolean
|
55
|
+
end
|
56
|
+
|
57
|
+
# GitHub CLI toolset for common GitHub operations
|
58
|
+
class GitHubCLIToolset < Toolset
|
59
|
+
extend T::Sig
|
60
|
+
|
61
|
+
toolset_name "github"
|
62
|
+
|
63
|
+
# Expose methods as tools with descriptions
|
64
|
+
tool :create_issue, description: "Create a new GitHub issue"
|
65
|
+
tool :create_pr, description: "Create a new GitHub pull request"
|
66
|
+
tool :list_issues, description: "List GitHub issues with optional filters"
|
67
|
+
tool :list_prs, description: "List GitHub pull requests with optional filters"
|
68
|
+
tool :get_issue, description: "Get details of a specific GitHub issue"
|
69
|
+
tool :get_pr, description: "Get details of a specific GitHub pull request"
|
70
|
+
tool :comment_on_issue, description: "Add a comment to a GitHub issue"
|
71
|
+
tool :review_pr, description: "Add a review to a GitHub pull request"
|
72
|
+
tool :api_request, description: "Make an arbitrary GitHub API request"
|
73
|
+
|
74
|
+
sig { void }
|
75
|
+
def initialize
|
76
|
+
# No persistent state needed
|
77
|
+
end
|
78
|
+
|
79
|
+
sig { params(
|
80
|
+
title: String,
|
81
|
+
body: String,
|
82
|
+
labels: T::Array[String],
|
83
|
+
assignees: T::Array[String],
|
84
|
+
repo: T.nilable(String)
|
85
|
+
).returns(String) }
|
86
|
+
def create_issue(title:, body:, labels: [], assignees: [], repo: nil)
|
87
|
+
cmd = build_gh_command(['issue', 'create'])
|
88
|
+
cmd << ['--title', shell_escape(title)]
|
89
|
+
cmd << ['--body', shell_escape(body)]
|
90
|
+
|
91
|
+
labels.each { |label| cmd << ['--label', shell_escape(label)] }
|
92
|
+
assignees.each { |assignee| cmd << ['--assignee', shell_escape(assignee)] }
|
93
|
+
|
94
|
+
if repo
|
95
|
+
cmd << ['--repo', shell_escape(repo)]
|
96
|
+
end
|
97
|
+
|
98
|
+
result = execute_command(cmd.flatten.join(' '))
|
99
|
+
|
100
|
+
if result[:success]
|
101
|
+
"Issue created successfully: #{result[:output].strip}"
|
102
|
+
else
|
103
|
+
"Failed to create issue: #{result[:error]}"
|
104
|
+
end
|
105
|
+
rescue => e
|
106
|
+
"Error creating issue: #{e.message}"
|
107
|
+
end
|
108
|
+
|
109
|
+
sig { params(
|
110
|
+
title: String,
|
111
|
+
body: String,
|
112
|
+
base: String,
|
113
|
+
head: String,
|
114
|
+
repo: T.nilable(String)
|
115
|
+
).returns(String) }
|
116
|
+
def create_pr(title:, body:, base:, head:, repo: nil)
|
117
|
+
cmd = build_gh_command(['pr', 'create'])
|
118
|
+
cmd << ['--title', shell_escape(title)]
|
119
|
+
cmd << ['--body', shell_escape(body)]
|
120
|
+
cmd << ['--base', shell_escape(base)]
|
121
|
+
cmd << ['--head', shell_escape(head)]
|
122
|
+
|
123
|
+
if repo
|
124
|
+
cmd << ['--repo', shell_escape(repo)]
|
125
|
+
end
|
126
|
+
|
127
|
+
result = execute_command(cmd.flatten.join(' '))
|
128
|
+
|
129
|
+
if result[:success]
|
130
|
+
"Pull request created successfully: #{result[:output].strip}"
|
131
|
+
else
|
132
|
+
"Failed to create pull request: #{result[:error]}"
|
133
|
+
end
|
134
|
+
rescue => e
|
135
|
+
"Error creating pull request: #{e.message}"
|
136
|
+
end
|
137
|
+
|
138
|
+
sig { params(
|
139
|
+
state: IssueState,
|
140
|
+
labels: T::Array[String],
|
141
|
+
assignee: T.nilable(String),
|
142
|
+
repo: T.nilable(String),
|
143
|
+
limit: Integer
|
144
|
+
).returns(String) }
|
145
|
+
def list_issues(state: IssueState::Open, labels: [], assignee: nil, repo: nil, limit: 20)
|
146
|
+
cmd = build_gh_command(['issue', 'list', '--json', 'number,title,state,labels,assignees,url'])
|
147
|
+
cmd << ['--state', state.serialize]
|
148
|
+
cmd << ['--limit', limit.to_s]
|
149
|
+
|
150
|
+
labels.each { |label| cmd << ['--label', shell_escape(label)] }
|
151
|
+
|
152
|
+
if assignee
|
153
|
+
cmd << ['--assignee', shell_escape(assignee)]
|
154
|
+
end
|
155
|
+
|
156
|
+
if repo
|
157
|
+
cmd << ['--repo', shell_escape(repo)]
|
158
|
+
end
|
159
|
+
|
160
|
+
result = execute_command(cmd.flatten.join(' '))
|
161
|
+
|
162
|
+
if result[:success]
|
163
|
+
parse_issue_list(result[:output])
|
164
|
+
else
|
165
|
+
"Failed to list issues: #{result[:error]}"
|
166
|
+
end
|
167
|
+
rescue => e
|
168
|
+
"Error listing issues: #{e.message}"
|
169
|
+
end
|
170
|
+
|
171
|
+
sig { params(
|
172
|
+
state: PRState,
|
173
|
+
author: T.nilable(String),
|
174
|
+
base: T.nilable(String),
|
175
|
+
repo: T.nilable(String),
|
176
|
+
limit: Integer
|
177
|
+
).returns(String) }
|
178
|
+
def list_prs(state: PRState::Open, author: nil, base: nil, repo: nil, limit: 20)
|
179
|
+
cmd = build_gh_command(['pr', 'list', '--json', 'number,title,state,baseRefName,headRefName,url'])
|
180
|
+
cmd << ['--state', state.serialize]
|
181
|
+
cmd << ['--limit', limit.to_s]
|
182
|
+
|
183
|
+
if author
|
184
|
+
cmd << ['--author', shell_escape(author)]
|
185
|
+
end
|
186
|
+
|
187
|
+
if base
|
188
|
+
cmd << ['--base', shell_escape(base)]
|
189
|
+
end
|
190
|
+
|
191
|
+
if repo
|
192
|
+
cmd << ['--repo', shell_escape(repo)]
|
193
|
+
end
|
194
|
+
|
195
|
+
result = execute_command(cmd.flatten.join(' '))
|
196
|
+
|
197
|
+
if result[:success]
|
198
|
+
parse_pr_list(result[:output])
|
199
|
+
else
|
200
|
+
"Failed to list pull requests: #{result[:error]}"
|
201
|
+
end
|
202
|
+
rescue => e
|
203
|
+
"Error listing pull requests: #{e.message}"
|
204
|
+
end
|
205
|
+
|
206
|
+
sig { params(issue_number: Integer, repo: T.nilable(String)).returns(String) }
|
207
|
+
def get_issue(issue_number:, repo: nil)
|
208
|
+
cmd = build_gh_command(['issue', 'view', issue_number.to_s, '--json', 'number,title,state,body,labels,assignees,url'])
|
209
|
+
|
210
|
+
if repo
|
211
|
+
cmd << ['--repo', shell_escape(repo)]
|
212
|
+
end
|
213
|
+
|
214
|
+
result = execute_command(cmd.flatten.join(' '))
|
215
|
+
|
216
|
+
if result[:success]
|
217
|
+
parse_issue_details(result[:output])
|
218
|
+
else
|
219
|
+
"Failed to get issue: #{result[:error]}"
|
220
|
+
end
|
221
|
+
rescue => e
|
222
|
+
"Error getting issue: #{e.message}"
|
223
|
+
end
|
224
|
+
|
225
|
+
sig { params(pr_number: Integer, repo: T.nilable(String)).returns(String) }
|
226
|
+
def get_pr(pr_number:, repo: nil)
|
227
|
+
cmd = build_gh_command(['pr', 'view', pr_number.to_s, '--json', 'number,title,state,body,baseRefName,headRefName,mergeable,url'])
|
228
|
+
|
229
|
+
if repo
|
230
|
+
cmd << ['--repo', shell_escape(repo)]
|
231
|
+
end
|
232
|
+
|
233
|
+
result = execute_command(cmd.flatten.join(' '))
|
234
|
+
|
235
|
+
if result[:success]
|
236
|
+
parse_pr_details(result[:output])
|
237
|
+
else
|
238
|
+
"Failed to get pull request: #{result[:error]}"
|
239
|
+
end
|
240
|
+
rescue => e
|
241
|
+
"Error getting pull request: #{e.message}"
|
242
|
+
end
|
243
|
+
|
244
|
+
sig { params(
|
245
|
+
issue_number: Integer,
|
246
|
+
comment: String,
|
247
|
+
repo: T.nilable(String)
|
248
|
+
).returns(String) }
|
249
|
+
def comment_on_issue(issue_number:, comment:, repo: nil)
|
250
|
+
cmd = build_gh_command(['issue', 'comment', issue_number.to_s])
|
251
|
+
cmd << ['--body', shell_escape(comment)]
|
252
|
+
|
253
|
+
if repo
|
254
|
+
cmd << ['--repo', shell_escape(repo)]
|
255
|
+
end
|
256
|
+
|
257
|
+
result = execute_command(cmd.flatten.join(' '))
|
258
|
+
|
259
|
+
if result[:success]
|
260
|
+
"Comment added successfully to issue ##{issue_number}"
|
261
|
+
else
|
262
|
+
"Failed to add comment: #{result[:error]}"
|
263
|
+
end
|
264
|
+
rescue => e
|
265
|
+
"Error adding comment: #{e.message}"
|
266
|
+
end
|
267
|
+
|
268
|
+
sig { params(
|
269
|
+
pr_number: Integer,
|
270
|
+
review_type: ReviewState,
|
271
|
+
comment: T.nilable(String),
|
272
|
+
repo: T.nilable(String)
|
273
|
+
).returns(String) }
|
274
|
+
def review_pr(pr_number:, review_type:, comment: nil, repo: nil)
|
275
|
+
cmd = build_gh_command(['pr', 'review', pr_number.to_s])
|
276
|
+
cmd << ['--' + review_type.serialize.tr('_', '-')]
|
277
|
+
|
278
|
+
if comment
|
279
|
+
cmd << ['--body', shell_escape(comment)]
|
280
|
+
end
|
281
|
+
|
282
|
+
if repo
|
283
|
+
cmd << ['--repo', shell_escape(repo)]
|
284
|
+
end
|
285
|
+
|
286
|
+
result = execute_command(cmd.flatten.join(' '))
|
287
|
+
|
288
|
+
if result[:success]
|
289
|
+
"Review added successfully to PR ##{pr_number}"
|
290
|
+
else
|
291
|
+
"Failed to add review: #{result[:error]}"
|
292
|
+
end
|
293
|
+
rescue => e
|
294
|
+
"Error adding review: #{e.message}"
|
295
|
+
end
|
296
|
+
|
297
|
+
sig { params(
|
298
|
+
endpoint: String,
|
299
|
+
method: String,
|
300
|
+
fields: T::Hash[String, String],
|
301
|
+
repo: T.nilable(String)
|
302
|
+
).returns(String) }
|
303
|
+
def api_request(endpoint:, method: 'GET', fields: {}, repo: nil)
|
304
|
+
cmd = build_gh_command(['api', endpoint])
|
305
|
+
cmd << ['--method', method.upcase]
|
306
|
+
|
307
|
+
fields.each do |key, value|
|
308
|
+
cmd << ['-f', "#{key}=#{shell_escape(value)}"]
|
309
|
+
end
|
310
|
+
|
311
|
+
if repo
|
312
|
+
cmd << ['--repo', shell_escape(repo)]
|
313
|
+
end
|
314
|
+
|
315
|
+
result = execute_command(cmd.flatten.join(' '))
|
316
|
+
|
317
|
+
if result[:success]
|
318
|
+
result[:output]
|
319
|
+
else
|
320
|
+
"API request failed: #{result[:error]}"
|
321
|
+
end
|
322
|
+
rescue => e
|
323
|
+
"Error making API request: #{e.message}"
|
324
|
+
end
|
325
|
+
|
326
|
+
private
|
327
|
+
|
328
|
+
sig { params(args: T::Array[String]).returns(T::Array[String]) }
|
329
|
+
def build_gh_command(args)
|
330
|
+
['gh'] + args
|
331
|
+
end
|
332
|
+
|
333
|
+
sig { params(str: String).returns(String) }
|
334
|
+
def shell_escape(str)
|
335
|
+
"\"#{str.gsub(/"/, '\\"')}\""
|
336
|
+
end
|
337
|
+
|
338
|
+
sig { params(cmd: String).returns(T::Hash[Symbol, T.untyped]) }
|
339
|
+
def execute_command(cmd)
|
340
|
+
output = `#{cmd} 2>&1`
|
341
|
+
success = Process.last_status.success?
|
342
|
+
|
343
|
+
{
|
344
|
+
success: success,
|
345
|
+
output: success ? output : '',
|
346
|
+
error: success ? '' : output
|
347
|
+
}
|
348
|
+
end
|
349
|
+
|
350
|
+
sig { params(json_output: String).returns(String) }
|
351
|
+
def parse_issue_list(json_output)
|
352
|
+
issues = JSON.parse(json_output)
|
353
|
+
|
354
|
+
if issues.empty?
|
355
|
+
"No issues found"
|
356
|
+
else
|
357
|
+
result = ["Found #{issues.length} issue(s):"]
|
358
|
+
issues.each do |issue|
|
359
|
+
labels = issue['labels']&.map { |l| l['name'] } || []
|
360
|
+
assignees = issue['assignees']&.map { |a| a['login'] } || []
|
361
|
+
|
362
|
+
result << "##{issue['number']}: #{issue['title']} (#{issue['state']})"
|
363
|
+
result << " Labels: #{labels.join(', ')}" unless labels.empty?
|
364
|
+
result << " Assignees: #{assignees.join(', ')}" unless assignees.empty?
|
365
|
+
result << " URL: #{issue['url']}"
|
366
|
+
result << ""
|
367
|
+
end
|
368
|
+
result.join("\n")
|
369
|
+
end
|
370
|
+
rescue JSON::ParserError => e
|
371
|
+
"Failed to parse issues data: #{e.message}"
|
372
|
+
end
|
373
|
+
|
374
|
+
sig { params(json_output: String).returns(String) }
|
375
|
+
def parse_pr_list(json_output)
|
376
|
+
prs = JSON.parse(json_output)
|
377
|
+
|
378
|
+
if prs.empty?
|
379
|
+
"No pull requests found"
|
380
|
+
else
|
381
|
+
result = ["Found #{prs.length} pull request(s):"]
|
382
|
+
prs.each do |pr|
|
383
|
+
result << "##{pr['number']}: #{pr['title']} (#{pr['state']})"
|
384
|
+
result << " #{pr['headRefName']} → #{pr['baseRefName']}"
|
385
|
+
result << " URL: #{pr['url']}"
|
386
|
+
result << ""
|
387
|
+
end
|
388
|
+
result.join("\n")
|
389
|
+
end
|
390
|
+
rescue JSON::ParserError => e
|
391
|
+
"Failed to parse pull requests data: #{e.message}"
|
392
|
+
end
|
393
|
+
|
394
|
+
sig { params(json_output: String).returns(String) }
|
395
|
+
def parse_issue_details(json_output)
|
396
|
+
issue = JSON.parse(json_output)
|
397
|
+
labels = issue['labels']&.map { |l| l['name'] } || []
|
398
|
+
assignees = issue['assignees']&.map { |a| a['login'] } || []
|
399
|
+
|
400
|
+
result = []
|
401
|
+
result << "Issue ##{issue['number']}: #{issue['title']}"
|
402
|
+
result << "State: #{issue['state']}"
|
403
|
+
result << "Labels: #{labels.join(', ')}" unless labels.empty?
|
404
|
+
result << "Assignees: #{assignees.join(', ')}" unless assignees.empty?
|
405
|
+
result << "URL: #{issue['url']}"
|
406
|
+
result << ""
|
407
|
+
result << "Body:"
|
408
|
+
body = issue['body']
|
409
|
+
result << (body && !body.empty? ? body : "No description provided")
|
410
|
+
|
411
|
+
result.join("\n")
|
412
|
+
rescue JSON::ParserError => e
|
413
|
+
"Failed to parse issue details: #{e.message}"
|
414
|
+
end
|
415
|
+
|
416
|
+
sig { params(json_output: String).returns(String) }
|
417
|
+
def parse_pr_details(json_output)
|
418
|
+
pr = JSON.parse(json_output)
|
419
|
+
|
420
|
+
result = []
|
421
|
+
result << "Pull Request ##{pr['number']}: #{pr['title']}"
|
422
|
+
result << "State: #{pr['state']}"
|
423
|
+
result << "Branch: #{pr['headRefName']} → #{pr['baseRefName']}"
|
424
|
+
result << "Mergeable: #{pr['mergeable'] ? 'Yes' : 'No'}"
|
425
|
+
result << "URL: #{pr['url']}"
|
426
|
+
result << ""
|
427
|
+
result << "Body:"
|
428
|
+
body = pr['body']
|
429
|
+
result << (body && !body.empty? ? body : "No description provided")
|
430
|
+
|
431
|
+
result.join("\n")
|
432
|
+
rescue JSON::ParserError => e
|
433
|
+
"Failed to parse pull request details: #{e.message}"
|
434
|
+
end
|
435
|
+
end
|
436
|
+
end
|
437
|
+
end
|