|
| 1 | +require 'fastlane/action' |
| 2 | +require 'net/http' |
| 3 | +require 'json' |
| 4 | + |
| 5 | +module Fastlane |
| 6 | + module Actions |
| 7 | + class OpenaiAskAction < Action |
| 8 | + OPENAI_API_ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze |
| 9 | + |
| 10 | + PREDEFINED_PROMPTS = { |
| 11 | + release_notes: <<~PROMPT.freeze |
| 12 | + Act like a mobile app marketer who wants to prepare release notes for Google Play and App Store. |
| 13 | + Do not write it point by point and keep it under 350 characters. It should be a unique paragraph. |
| 14 | +
|
| 15 | + When provided a list, use the number of any potential "*" in brackets at the start of each item as indicator of importance. |
| 16 | + Ignore items starting with "[Internal]", and ignore links to GitHub. |
| 17 | + PROMPT |
| 18 | + }.freeze |
| 19 | + |
| 20 | + def self.run(params) |
| 21 | + api_token = params[:api_token] |
| 22 | + prompt = params[:prompt] |
| 23 | + prompt = PREDEFINED_PROMPTS[prompt] if PREDEFINED_PROMPTS.key?(prompt) |
| 24 | + question = params[:question] |
| 25 | + |
| 26 | + headers = { |
| 27 | + 'Content-Type': 'application/json', |
| 28 | + Authorization: "Bearer #{api_token}" |
| 29 | + } |
| 30 | + body = request_body(prompt: prompt, question: question) |
| 31 | + |
| 32 | + response = Net::HTTP.post(OPENAI_API_ENDPOINT, body, headers) |
| 33 | + |
| 34 | + case response |
| 35 | + when Net::HTTPOK |
| 36 | + json = JSON.parse(response.body) |
| 37 | + json['choices']&.first&.dig('message', 'content') |
| 38 | + else |
| 39 | + UI.user_error!("Error in OpenAI API response: #{response}. #{response.body}") |
| 40 | + end |
| 41 | + end |
| 42 | + |
| 43 | + def self.request_body(prompt:, question:) |
| 44 | + { |
| 45 | + model: 'gpt-4o', |
| 46 | + response_format: { type: 'text' }, |
| 47 | + temperature: 1, |
| 48 | + max_tokens: 2048, |
| 49 | + top_p: 1, |
| 50 | + messages: [ |
| 51 | + format_message(role: 'system', text: prompt), |
| 52 | + format_message(role: 'user', text: question), |
| 53 | + ].compact |
| 54 | + }.to_json |
| 55 | + end |
| 56 | + |
| 57 | + def self.format_message(role:, text:) |
| 58 | + return nil if text.nil? || text.empty? |
| 59 | + |
| 60 | + { |
| 61 | + role: role, |
| 62 | + content: [{ type: 'text', text: text }] |
| 63 | + } |
| 64 | + end |
| 65 | + |
| 66 | + ##################################################### |
| 67 | + # @!group Documentation |
| 68 | + ##################################################### |
| 69 | + |
| 70 | + def self.description |
| 71 | + 'Use OpenAI API to generate response to a prompt' |
| 72 | + end |
| 73 | + |
| 74 | + def self.authors |
| 75 | + ['Automattic'] |
| 76 | + end |
| 77 | + |
| 78 | + def self.return_value |
| 79 | + 'The response text from the prompt as returned by OpenAI API' |
| 80 | + end |
| 81 | + |
| 82 | + def self.details |
| 83 | + <<~DETAILS |
| 84 | + Uses the OpenAI API to generate response to a prompt. |
| 85 | + Can be used to e.g. ask it to generate Release Notes based on a bullet point technical changelog or similar. |
| 86 | + DETAILS |
| 87 | + end |
| 88 | + |
| 89 | + def self.examples |
| 90 | + [ |
| 91 | + <<~EXEMPLE, |
| 92 | + items = extract_release_notes_for_version(version: app_version, release_notes_file_path: 'RELEASE-NOTES.txt') |
| 93 | + nice_changelog = openai_ask( |
| 94 | + prompt: :release_notes, # Uses the pre-crafted prompt for App Store / Play Store release notes |
| 95 | + question: "Help me write release notes for the following items:\n#{items}", |
| 96 | + api_token: get_required_env('OPENAI_API_TOKEN') |
| 97 | + ) |
| 98 | + File.write(File.join('fastlane', 'metadata', 'android', 'en-US', 'changelogs', 'default.txt'), nice_changelog) |
| 99 | + EXEMPLE |
| 100 | + ] |
| 101 | + end |
| 102 | + |
| 103 | + def self.available_prompt_symbols |
| 104 | + PREDEFINED_PROMPTS.keys.map { |v| "`:#{v}`" }.join(',') |
| 105 | + end |
| 106 | + |
| 107 | + def self.available_options |
| 108 | + [ |
| 109 | + FastlaneCore::ConfigItem.new(key: :prompt, |
| 110 | + description: 'The internal top-level instructions to give to the model to tell it how to behave. ' \ |
| 111 | + + "Use a Ruby Symbol from one of [#{available_prompt_symbols}] to use a predefined prompt instead of writing your own", |
| 112 | + optional: true, |
| 113 | + default_value: nil, |
| 114 | + type: String, |
| 115 | + skip_type_validation: true, |
| 116 | + verify_block: proc do |value| |
| 117 | + next if value.is_a?(String) |
| 118 | + next if PREDEFINED_PROMPTS.include?(value) |
| 119 | + |
| 120 | + UI.user_error!("Parameter `prompt` can only be a String or one of the following Symbols: [#{available_prompt_symbols}]") |
| 121 | + end), |
| 122 | + FastlaneCore::ConfigItem.new(key: :question, |
| 123 | + description: 'The user message to ask the question to the OpenAI model', |
| 124 | + optional: false, |
| 125 | + default_value: nil, |
| 126 | + type: String), |
| 127 | + FastlaneCore::ConfigItem.new(key: :api_token, |
| 128 | + description: 'The OpenAI API Token to use for the request', |
| 129 | + env_name: 'OPENAI_API_TOKEN', |
| 130 | + optional: false, |
| 131 | + sensitive: true, |
| 132 | + type: String), |
| 133 | + ] |
| 134 | + end |
| 135 | + |
| 136 | + def self.is_supported?(_platform) |
| 137 | + true |
| 138 | + end |
| 139 | + end |
| 140 | + end |
| 141 | +end |
0 commit comments