Introduce AI review chat integration with ChatGPT Change-Id: I585b7d75b55ba51686dd2d86f932e599d3ed1efd
diff --git a/ai/ai-review-agent-chatgpt-1.0.groovy b/ai/ai-review-agent-chatgpt-1.0.groovy new file mode 100644 index 0000000..6de8861 --- /dev/null +++ b/ai/ai-review-agent-chatgpt-1.0.groovy
@@ -0,0 +1,135 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import com.gerritforge.gerrit.plugins.ai.provider.api.* + +import com.google.common.flogger.FluentLogger +import com.google.gerrit.extensions.registration.DynamicSet +import com.google.inject.* + +import org.apache.http.* +import org.apache.http.message.* +import org.apache.http.entity.StringEntity + +import java.nio.charset.StandardCharsets +import org.apache.http.HttpHeaders + +import groovy.json.* + +@Singleton +class AiChatGptReviewProvider implements AiReviewProvider { + private static final FluentLogger logger = FluentLogger.forEnclosingClass() + private static final String OPENAI_API_URL = 'https://api.openai.com/v1' + private static final int MAX_ERROR_LEN = 500 + private static final float CREATIVITY_TEMPERATURE = 0.7 + + final String displayName = 'ChatGPT' + + @Inject + private AiHttpClient http + + @Override + Set<String> getModels(String apiKey) { + try { + http.get("${OPENAI_API_URL}/models", + [authHeader(apiKey)] as Header[], + { extractErrorMessage(it) }, + { extractModelsList(it) }) as Set + } catch (IOException | JsonException e) { + logger.atWarning().withCause(e).log("Failed to fetch OpenAI models") + [] as Set + } + } + + @Override + String review(String apiKey, String model, String prompt) { + try { + def requestPayload = [ + model: model, + input: prompt + ] + if (!isReasoningModel(model)) { + requestPayload.temperature = CREATIVITY_TEMPERATURE + } + http.post("${OPENAI_API_URL}/responses", + [http.contentTypeApplicationJson(), authHeader(apiKey)] as Header[], + new StringEntity(new JsonBuilder(requestPayload).toString(), StandardCharsets.UTF_8), + { extractErrorMessage(it) }, + { extractResponseText(it) }) + } catch (IOException | JsonException e) { + logger.atWarning().withCause(e).log('Failed to call OpenAI API (model=%s)', model) + throw new IllegalStateException('Failed to call OpenAI API', e) + } + } + + private static boolean isReasoningModel(String model) { + model.startsWith('o1') || model.startsWith('o3') + } + + private static Header authHeader(String apiKey) { + new BasicHeader(HttpHeaders.AUTHORIZATION, "Bearer $apiKey") + } + + private static Set<String> extractModelsList(String body) { + def json = new JsonSlurper().parseText(body) + + if (!json.data) { + throw new IOException('OpenAI API response contains no data array for models') + } + + return json.data.collect { it.id as String } + .findAll { it.startsWith('gpt-') } as Set + } + + private static String extractResponseText(String body) { + def json = new JsonSlurper().parseText(body) + + def textContent = new StringBuilder() + + json.output?.each { item -> + if (item.type == 'message' && item.role == 'assistant') { + item.content?.each { contentBlock -> + if (contentBlock.type == 'output_text') { + textContent.append(contentBlock.text) + } + } + } + } + + if (textContent.length() == 0) { + throw new IOException('OpenAI API response contains no valid text output') + } + + return textContent.toString().trim() + } + + private static String extractErrorMessage(String body) { + try { + def json = new JsonSlurper().parseText(body) + if (json?.error) return "[${json.error.code ?: 'Error'}] ${json.error.message}" + } catch (Exception e) { + logger.atWarning().withCause(e).log('Failed to parse error response') + } + return body.length() > MAX_ERROR_LEN ? "${body.take(MAX_ERROR_LEN)}..." : body + } +} + +class AiChatGptModule extends AbstractModule { + @Override + protected void configure() { + DynamicSet.bind(binder(), AiReviewProvider).to(AiChatGptReviewProvider) + } +} + +module = AiChatGptModule \ No newline at end of file
diff --git a/ai/ai-review-agent-chatgpt.md b/ai/ai-review-agent-chatgpt.md new file mode 100644 index 0000000..da06166 --- /dev/null +++ b/ai/ai-review-agent-chatgpt.md
@@ -0,0 +1,35 @@ +# Gerrit AI Review Agent for ChatGPT + +Implementation of Gerrit's AI Code Review Agent API on top of OpenAI's ChatGPT. + +[Install](#install-in-gerrit) this plugin and enable the Gerrit AI chat to enjoy +a side-by-side collaboration with ChatGPT on the Change screen. + +## License + +This script is licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +## How to use + +### Prerequisites + +Gerrit v3.14 or later with the following additional plugins: + +- [Groovy scripting provider](https://github.com/GerritForge/groovy-provider/) +- [GerritForge's AI Review Agent Provider](https://github.com/GerritForge/ai-review-agent-provider) + +### [Install in Gerrit](#install-in-gerrit) + +Copy the `ai-review-agent-chatgpt-1.0.groovy` script into your Gerrit site (`$GERRIT_SITE`) +plugins' directory. + +```bash +cp ai-review-agent-chatgpt-1.0.groovy "$GERRIT_SITE/plugins/" +``` \ No newline at end of file