fastlane-plugin-wpmreleasetoolkit 14.4.1 → 14.6.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: 53fda01b5c9773e9763663cbce794b14dfc05895fd4811832244b10419b57290
4
- data.tar.gz: 39baf709e7720943a6e403859515bc0dcfd5d8538afe74ae258d24d5586c7c0b
3
+ metadata.gz: 787be90be7c62894c1cbee1a45d28d7815991f7158a654703f31c8db0d541f61
4
+ data.tar.gz: 8f6312bdc9f3ecd5f9fbc17a78d22b91483cd7ee7d4e4fa511b9bb367a26c08c
5
5
  SHA512:
6
- metadata.gz: 5bc8522d6cb6cde402d7cf99b629cc12fc2a407f13bcc908833440175a7a22a7dbb8aac2ed860dad902d10ef880c0d97478f12ada9d0dfccaaf651442b4e2d3c
7
- data.tar.gz: 0b7bc991e3742c49e8b2717a2dc8207c0272c01cdda7110e99852ea52ff4651053c8d2dcd9372cba615a52b816dc829dc11f72db7c3fc6afc48061802a6e6f3b
6
+ metadata.gz: f23b5d15c71550aa20af5536f2f6517a780f4cc2a0a286e21316a431e803bcd29e66dd2e93432d1998ebb922ff72b80f0fbeaabcbf5472fb1dbf3dd600900134
7
+ data.tar.gz: a69a5537a36109cdb5442c3a0c302ccc0bc8702c6380c6ac68c4c9a7beb95d0777996f53505de54f4142a4fd1f9cd6e1637d726c917a751f43529d9427825fe9
@@ -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
- response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers)
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
- case response
37
- when Net::HTTPOK
38
- json = JSON.parse(response.body)
39
- json['choices']&.first&.dig('message', 'content')
40
- else
41
- UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}")
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: 'gpt-4o',
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
- <<~EXEMPLE,
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
- EXEMPLE
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
 
@@ -40,6 +40,8 @@ module Fastlane
40
40
  'Microsoft Store - x86',
41
41
  'Microsoft Store - x64',
42
42
  'Microsoft Store - ARM64',
43
+ 'Linux - x64',
44
+ 'Linux - ARM64',
43
45
  ].freeze
44
46
  # See https://github.a8c.com/Automattic/wpcom/blob/trunk/wp-content/lib/a8c/cdn/src/enums/enum-install-type.php
45
47
  VALID_INSTALL_TYPES = [
@@ -3,6 +3,6 @@
3
3
  module Fastlane
4
4
  module Wpmreleasetoolkit
5
5
  NAME = 'fastlane-plugin-wpmreleasetoolkit'
6
- VERSION = '14.4.1'
6
+ VERSION = '14.6.0'
7
7
  end
8
8
  end
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.1
4
+ version: 14.6.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-05-07 00:00:00.000000000 Z
11
+ date: 2026-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: buildkit