fastlane-plugin-wpmreleasetoolkit 14.4.0 → 14.5.0
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f94dfd4c83abe08fe06992420c527d344babf75a8ae1077b5a6f827d0fa04e3
|
|
4
|
+
data.tar.gz: 8a97600b84a243ca467bc2ee3323bfd6787dc9a9d8dc2788f38a84787ce6a8f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 221e128a2b76ced47cf0b2f135df1532999f9608d53d0ef1ed22b8b2089b8256c2463da25d6ddf661e35391e73c2b93a6d108b0b5c596e0d92b0901ce30da7c5
|
|
7
|
+
data.tar.gz: 0dd115888f8836e0b9cc01deb71f77337fd5cc77c241c432006e625b97466e7055410ff7fa1e5ae167a681e0c451e98230dc213ad3584f1183c537a2e1788d8a
|
|
@@ -8,6 +8,8 @@ module Fastlane
|
|
|
8
8
|
module Actions
|
|
9
9
|
class OpenaiAskAction < Action
|
|
10
10
|
OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze
|
|
11
|
+
DEFAULT_MAX_TOOL_ITERATIONS = 5
|
|
12
|
+
DEFAULT_MODEL = 'gpt-4o'
|
|
11
13
|
|
|
12
14
|
PREDEFINED_PROMPTS = {
|
|
13
15
|
release_notes: <<~PROMPT
|
|
@@ -24,27 +26,68 @@ module Fastlane
|
|
|
24
26
|
prompt = params[:prompt]
|
|
25
27
|
prompt = PREDEFINED_PROMPTS[prompt] if PREDEFINED_PROMPTS.key?(prompt)
|
|
26
28
|
question = params[:question]
|
|
29
|
+
model = params[:model] || DEFAULT_MODEL
|
|
30
|
+
tools = params[:tools]
|
|
31
|
+
# Tool names from the OpenAI API are always JSON strings. Normalize handler keys so
|
|
32
|
+
# callers can register handlers with either string or symbol keys without surprises.
|
|
33
|
+
tool_handlers = (params[:tool_handlers] || {}).transform_keys(&:to_s)
|
|
34
|
+
max_tool_iterations = params[:max_tool_iterations] || DEFAULT_MAX_TOOL_ITERATIONS
|
|
27
35
|
|
|
28
36
|
headers = {
|
|
29
37
|
'Content-Type': 'application/json',
|
|
30
38
|
Authorization: "Bearer #{api_token}"
|
|
31
39
|
}
|
|
32
|
-
body = request_body(prompt: prompt, question: question)
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
# Backwards-compatible single-shot path when no tools are provided.
|
|
42
|
+
if tools.nil? || tools.empty?
|
|
43
|
+
body = request_body(prompt: prompt, question: question, model: model)
|
|
44
|
+
response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
|
|
45
|
+
return parse_text_response(response)
|
|
46
|
+
end
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
run_with_tools(
|
|
49
|
+
prompt: prompt,
|
|
50
|
+
question: question,
|
|
51
|
+
model: model,
|
|
52
|
+
tools: tools,
|
|
53
|
+
tool_handlers: tool_handlers,
|
|
54
|
+
max_tool_iterations: max_tool_iterations,
|
|
55
|
+
headers: headers
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.run_with_tools(prompt:, question:, model:, tools:, tool_handlers:, max_tool_iterations:, headers:)
|
|
60
|
+
messages = [
|
|
61
|
+
format_message(role: 'system', text: prompt),
|
|
62
|
+
format_message(role: 'user', text: question),
|
|
63
|
+
].compact
|
|
64
|
+
|
|
65
|
+
max_tool_iterations.times do
|
|
66
|
+
body = request_body_with_messages(messages: messages, tools: tools, model: model)
|
|
67
|
+
response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
|
|
68
|
+
assistant_message = parse_assistant_message(response)
|
|
69
|
+
tool_calls = assistant_message['tool_calls']
|
|
70
|
+
|
|
71
|
+
# No tool calls — model produced a final answer.
|
|
72
|
+
return assistant_message['content'] if tool_calls.nil? || tool_calls.empty?
|
|
73
|
+
|
|
74
|
+
# Append the assistant's tool-call message verbatim, then run each handler
|
|
75
|
+
# and append the corresponding `role: tool` results.
|
|
76
|
+
messages << assistant_message
|
|
77
|
+
tool_calls.each do |tool_call|
|
|
78
|
+
messages << execute_tool_call(tool_call, tool_handlers)
|
|
79
|
+
end
|
|
42
80
|
end
|
|
81
|
+
|
|
82
|
+
UI.user_error!(
|
|
83
|
+
"OpenAI tool-use loop did not terminate after #{max_tool_iterations} iterations. " \
|
|
84
|
+
'Increase `max_tool_iterations` or check that your prompt instructs the model to stop calling tools.'
|
|
85
|
+
)
|
|
43
86
|
end
|
|
44
87
|
|
|
45
|
-
def self.request_body(prompt:, question:)
|
|
88
|
+
def self.request_body(prompt:, question:, model: DEFAULT_MODEL)
|
|
46
89
|
{
|
|
47
|
-
model:
|
|
90
|
+
model: model,
|
|
48
91
|
response_format: { type: 'text' },
|
|
49
92
|
temperature: 1,
|
|
50
93
|
max_tokens: 2048,
|
|
@@ -56,6 +99,18 @@ module Fastlane
|
|
|
56
99
|
}.to_json
|
|
57
100
|
end
|
|
58
101
|
|
|
102
|
+
def self.request_body_with_messages(messages:, tools:, model: DEFAULT_MODEL)
|
|
103
|
+
{
|
|
104
|
+
model: model,
|
|
105
|
+
response_format: { type: 'text' },
|
|
106
|
+
temperature: 1,
|
|
107
|
+
max_tokens: 2048,
|
|
108
|
+
top_p: 1,
|
|
109
|
+
messages: messages,
|
|
110
|
+
tools: tools
|
|
111
|
+
}.to_json
|
|
112
|
+
end
|
|
113
|
+
|
|
59
114
|
def self.format_message(role:, text:)
|
|
60
115
|
return nil if text.nil? || text.empty?
|
|
61
116
|
|
|
@@ -65,6 +120,89 @@ module Fastlane
|
|
|
65
120
|
}
|
|
66
121
|
end
|
|
67
122
|
|
|
123
|
+
def self.parse_text_response(response)
|
|
124
|
+
case response
|
|
125
|
+
when Net::HTTPOK
|
|
126
|
+
json = JSON.parse(response.body)
|
|
127
|
+
json['choices']&.first&.dig('message', 'content')
|
|
128
|
+
else
|
|
129
|
+
UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.parse_assistant_message(response)
|
|
134
|
+
case response
|
|
135
|
+
when Net::HTTPOK
|
|
136
|
+
json = JSON.parse(response.body)
|
|
137
|
+
json['choices']&.first&.dig('message') || {}
|
|
138
|
+
else
|
|
139
|
+
UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.execute_tool_call(tool_call, tool_handlers)
|
|
144
|
+
name = tool_call.dig('function', 'name')
|
|
145
|
+
raw_args = tool_call.dig('function', 'arguments') || '{}'
|
|
146
|
+
|
|
147
|
+
result =
|
|
148
|
+
begin
|
|
149
|
+
args = JSON.parse(raw_args)
|
|
150
|
+
invoke_tool_handler(name: name, handler: tool_handlers[name], args: args)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
# Short-circuit: the handler never sees malformed args. Tell the model the
|
|
153
|
+
# tool-call payload was invalid so it can retry with valid JSON, and log the
|
|
154
|
+
# raw arguments locally for debugging without forwarding them to the API.
|
|
155
|
+
UI.error("Invalid JSON arguments for tool '#{name}'. Raw payload: #{raw_args}")
|
|
156
|
+
{ error: "Invalid JSON arguments for tool '#{name}' — payload could not be parsed. Retry with valid JSON." }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
role: 'tool',
|
|
161
|
+
tool_call_id: tool_call['id'],
|
|
162
|
+
content: serialize_tool_result(name: name, result: result)
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Serializes a tool result to a JSON string. Handlers are contracted to return
|
|
167
|
+
# JSON-serializable values, but a buggy handler might return something like a
|
|
168
|
+
# `Pathname`, `Proc`, or a custom object whose `to_json` raises. Failing the
|
|
169
|
+
# whole conversation over a serialization error is harsh — instead, log locally
|
|
170
|
+
# and send a structured `{ error: ... }` back so the model can recover.
|
|
171
|
+
#
|
|
172
|
+
# The handler's class name is exposed (handler authorship is local, not secret)
|
|
173
|
+
# but the exception's message is NOT forwarded — same reasoning as
|
|
174
|
+
# `invoke_tool_handler`: handler-returned objects can carry secrets.
|
|
175
|
+
def self.serialize_tool_result(name:, result:)
|
|
176
|
+
JSON.generate(result)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
UI.error("Could not serialize tool result for '#{name}': #{e.class}: #{e.message}. Result class: #{result.class}")
|
|
179
|
+
JSON.generate({ error: "Tool result for '#{name}' could not be serialized to JSON. Returned class: #{result.class}." })
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Invokes a tool handler safely. Returns a JSON-serializable value that will be
|
|
183
|
+
# sent back to the model as the `content` of a `role: tool` message (the value
|
|
184
|
+
# may be a Hash, Array, scalar, etc. — whatever the handler returns).
|
|
185
|
+
#
|
|
186
|
+
# - Missing or non-callable handler: structured `{ error: ... }` so the model can recover.
|
|
187
|
+
# - Handler raised: structured `{ error:, exception: }` carrying only the exception class
|
|
188
|
+
# so the model can see the failure category and adjust. The full message and backtrace
|
|
189
|
+
# are logged locally via `UI.error` but NOT forwarded to the model, because tool
|
|
190
|
+
# results are sent to OpenAI and handler exception messages can contain secrets
|
|
191
|
+
# (tokens, file contents, internal API responses). The loop keeps going rather than
|
|
192
|
+
# aborting the lane mid-conversation — the model is the better judge of whether the
|
|
193
|
+
# failure is recoverable than a global `rescue` here.
|
|
194
|
+
def self.invoke_tool_handler(name:, handler:, args:)
|
|
195
|
+
return { error: "No handler defined for tool '#{name}'" } if handler.nil?
|
|
196
|
+
return { error: "Handler for tool '#{name}' is not callable (got #{handler.class})" } unless handler.respond_to?(:call)
|
|
197
|
+
|
|
198
|
+
begin
|
|
199
|
+
handler.call(args)
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
UI.error("Handler for tool '#{name}' raised #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
|
202
|
+
{ error: "Handler for tool '#{name}' raised an exception", exception: e.class.name }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
68
206
|
#####################################################
|
|
69
207
|
# @!group Documentation
|
|
70
208
|
#####################################################
|
|
@@ -78,19 +216,25 @@ module Fastlane
|
|
|
78
216
|
end
|
|
79
217
|
|
|
80
218
|
def self.return_value
|
|
81
|
-
'The response text from the prompt as returned by OpenAI API'
|
|
219
|
+
'The response text from the prompt as returned by OpenAI API. ' \
|
|
220
|
+
'When `tools` are provided, returns the assistant content from the first turn that produces a non-tool-call response.'
|
|
82
221
|
end
|
|
83
222
|
|
|
84
223
|
def self.details
|
|
85
224
|
<<~DETAILS
|
|
86
225
|
Uses the OpenAI API to generate response to a prompt.
|
|
87
226
|
Can be used to e.g. ask it to generate Release Notes based on a bullet point technical changelog or similar.
|
|
227
|
+
|
|
228
|
+
When `tools` and `tool_handlers` are provided, the action runs a tool-use (function-calling) loop:
|
|
229
|
+
on each turn, if the model calls one or more tools, the corresponding handler is invoked locally
|
|
230
|
+
and its return value is sent back to the model as a `role: tool` message. The loop ends when the
|
|
231
|
+
model returns a plain text response, or when `max_tool_iterations` is reached.
|
|
88
232
|
DETAILS
|
|
89
233
|
end
|
|
90
234
|
|
|
91
235
|
def self.examples
|
|
92
236
|
[
|
|
93
|
-
<<~
|
|
237
|
+
<<~'EXAMPLE',
|
|
94
238
|
items = extract_release_notes_for_version(version: app_version, release_notes_file_path: 'RELEASE-NOTES.txt')
|
|
95
239
|
nice_changelog = openai_ask(
|
|
96
240
|
prompt: :release_notes, # Uses the pre-crafted prompt for App Store / Play Store release notes
|
|
@@ -98,7 +242,36 @@ module Fastlane
|
|
|
98
242
|
api_token: get_required_env('OPENAI_API_TOKEN')
|
|
99
243
|
)
|
|
100
244
|
File.write(File.join('fastlane', 'metadata', 'android', 'en-US', 'changelogs', 'default.txt'), nice_changelog)
|
|
101
|
-
|
|
245
|
+
EXAMPLE
|
|
246
|
+
<<~'EXAMPLE',
|
|
247
|
+
# Tool-use loop: the model proposes release notes via a tool call; the handler validates
|
|
248
|
+
# length locally and rejects until the model produces text under the limit.
|
|
249
|
+
notes = openai_ask(
|
|
250
|
+
prompt: :release_notes,
|
|
251
|
+
question: "Write release notes for: #{items}. Call the validate_length tool with your draft and iterate until it accepts.",
|
|
252
|
+
api_token: get_required_env('OPENAI_API_TOKEN'),
|
|
253
|
+
tools: [{
|
|
254
|
+
type: 'function',
|
|
255
|
+
function: {
|
|
256
|
+
name: 'validate_length',
|
|
257
|
+
description: 'Validates the length of the proposed release notes against a 350-character budget. ' \
|
|
258
|
+
'Returns `{ ok: true, length: }` if the text fits, or `{ ok: false, length:, max: }` otherwise. ' \
|
|
259
|
+
'Call repeatedly with shorter drafts until it returns ok: true.',
|
|
260
|
+
parameters: {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: { text: { type: 'string' } },
|
|
263
|
+
required: ['text']
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}],
|
|
267
|
+
tool_handlers: {
|
|
268
|
+
'validate_length' => ->(args) {
|
|
269
|
+
len = args['text'].length
|
|
270
|
+
len <= 350 ? { ok: true, length: len } : { ok: false, length: len, max: 350 }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
EXAMPLE
|
|
102
275
|
]
|
|
103
276
|
end
|
|
104
277
|
|
|
@@ -132,6 +305,41 @@ module Fastlane
|
|
|
132
305
|
optional: false,
|
|
133
306
|
sensitive: true,
|
|
134
307
|
type: String),
|
|
308
|
+
FastlaneCore::ConfigItem.new(key: :model,
|
|
309
|
+
description: 'The OpenAI model to send the request to (e.g. `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`). ' \
|
|
310
|
+
"Defaults to `#{DEFAULT_MODEL}`",
|
|
311
|
+
optional: true,
|
|
312
|
+
default_value: DEFAULT_MODEL,
|
|
313
|
+
type: String),
|
|
314
|
+
FastlaneCore::ConfigItem.new(key: :tools,
|
|
315
|
+
description: 'Optional array of tool (function-calling) definitions in OpenAI format. ' \
|
|
316
|
+
'When provided, the action runs a tool-use loop',
|
|
317
|
+
optional: true,
|
|
318
|
+
default_value: nil,
|
|
319
|
+
type: Array,
|
|
320
|
+
verify_block: proc do |value|
|
|
321
|
+
UI.user_error!('Parameter `tools` must be a non-empty Array when provided') if value.empty?
|
|
322
|
+
end),
|
|
323
|
+
FastlaneCore::ConfigItem.new(key: :tool_handlers,
|
|
324
|
+
description: 'Hash of tool name to a callable (e.g. a Proc) invoked when the model calls that tool. ' \
|
|
325
|
+
'The callable receives the parsed arguments Hash and must return a JSON-serializable value, ' \
|
|
326
|
+
'which is sent back to the model as the tool result',
|
|
327
|
+
optional: true,
|
|
328
|
+
default_value: nil,
|
|
329
|
+
type: Hash,
|
|
330
|
+
verify_block: proc do |value|
|
|
331
|
+
non_callable = value.reject { |_k, v| v.respond_to?(:call) }
|
|
332
|
+
UI.user_error!("Parameter `tool_handlers` values must respond to :call. Non-callable handlers: #{non_callable.keys}") if non_callable.any?
|
|
333
|
+
end),
|
|
334
|
+
FastlaneCore::ConfigItem.new(key: :max_tool_iterations,
|
|
335
|
+
description: 'Maximum number of tool-use loop iterations before the action fails. ' \
|
|
336
|
+
'Only used when `tools` are provided',
|
|
337
|
+
optional: true,
|
|
338
|
+
default_value: DEFAULT_MAX_TOOL_ITERATIONS,
|
|
339
|
+
type: Integer,
|
|
340
|
+
verify_block: proc do |value|
|
|
341
|
+
UI.user_error!("Parameter `max_tool_iterations` must be >= 1 (got #{value})") if value < 1
|
|
342
|
+
end),
|
|
135
343
|
]
|
|
136
344
|
end
|
|
137
345
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fastlane-plugin-wpmreleasetoolkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 14.
|
|
4
|
+
version: 14.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Automattic
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: buildkit
|
|
@@ -128,14 +128,20 @@ dependencies:
|
|
|
128
128
|
requirements:
|
|
129
129
|
- - "~>"
|
|
130
130
|
- !ruby/object:Gem::Version
|
|
131
|
-
version: '1.
|
|
131
|
+
version: '1.19'
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: 1.19.3
|
|
132
135
|
type: :runtime
|
|
133
136
|
prerelease: false
|
|
134
137
|
version_requirements: !ruby/object:Gem::Requirement
|
|
135
138
|
requirements:
|
|
136
139
|
- - "~>"
|
|
137
140
|
- !ruby/object:Gem::Version
|
|
138
|
-
version: '1.
|
|
141
|
+
version: '1.19'
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: 1.19.3
|
|
139
145
|
- !ruby/object:Gem::Dependency
|
|
140
146
|
name: octokit
|
|
141
147
|
requirement: !ruby/object:Gem::Requirement
|