Introduce AI review chat integration with Claude Change-Id: I8bc4002c6684052c4e3c8379f4e9851538d628a6
diff --git a/ai/ai-review-agent-claude-1.0.groovy b/ai/ai-review-agent-claude-1.0.groovy new file mode 100644 index 0000000..bdeb3c0 --- /dev/null +++ b/ai/ai-review-agent-claude-1.0.groovy
@@ -0,0 +1,131 @@ +// 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 groovy.json.* + +@Singleton +class AiClaudeReviewProvider implements AiReviewProvider { + private static final FluentLogger logger = FluentLogger.forEnclosingClass() + private static final String CLAUDE_API_URL = 'https://api.anthropic.com/v1' + private static final String API_KEY_HEADER = 'x-api-key' + private static final Header ANTHROPIC_VERSION_HEADER = new BasicHeader('anthropic-version', '2023-06-01') + private static final int MAX_ERROR_LEN = 500 + /* + * Lowest max_tokens for the current Claude models, Claude Sonnet 4.6 / Haiku 4.5 + * Reference: https://platform.claude.com/docs/en/about-claude/models/overview + */ + private static final int MAX_TOKENS = 65536 + + final String displayName = 'Claude' + + @Inject + private AiHttpClient http + + @Override + Set<String> getModels(String apiKey) { + try { + http.get( + "${CLAUDE_API_URL}/models", + [http.acceptApplicationJson(), apiKeyHeader(apiKey), ANTHROPIC_VERSION_HEADER] as Header[], + { extractErrorMessage(it) }, + { extractResponseModels(it) }) + } catch (IOException | JsonException e) { + logger.atWarning().withCause(e).log('Failed to call Claude API to get the list of models') + [] as Set + } + } + + + @Override + String review(String apiKey, String model, String prompt) { + try { + http.post("${CLAUDE_API_URL}/messages", + [http.contentTypeApplicationJson(), apiKeyHeader(apiKey), ANTHROPIC_VERSION_HEADER] as Header[], + new StringEntity(new JsonBuilder([ + model : model, + max_tokens: MAX_TOKENS, + messages : [ + [role: 'user', content: prompt] + ] + ]).toString(), StandardCharsets.UTF_8), + { extractErrorMessage(it) }, + { extractResponseText(it) }) + } + catch (IOException | JsonException e) { + logger.atWarning().withCause(e).log('Failed to call Claude API (model=%s)', model) + throw new IllegalStateException('Failed to call Claude API', e) + } + } + + private static Header apiKeyHeader(String apiKey) { + new BasicHeader(API_KEY_HEADER, apiKey) + } + + private static String extractResponseText(String body) { + def json = new JsonSlurper().parseText(body) + + def textContent = json.content?.find { it.type == 'text' }?.text + + if (!textContent) { + throw new IOException('Claude API response contains no text content') + } + + return textContent + } + + private static Set<String> extractResponseModels(String body) { + def json = new JsonSlurper().parseText(body) + + def modelsIds = json.data?.collect { it.id } + + if (!modelsIds) { + throw new IOException('Claude API response contains no models') + } + + return modelsIds as Set + } + + private static String extractErrorMessage(String body) { + try { + def json = new JsonSlurper().parseText(body) + if (json?.error) return "[${json.error.type}] ${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 AiClaudeModule extends AbstractModule { + @Override + protected void configure() { + DynamicSet.bind(binder(), AiReviewProvider).to(AiClaudeReviewProvider) + } +} + +module = AiClaudeModule \ No newline at end of file
diff --git a/ai/ai-review-agent-claude.md b/ai/ai-review-agent-claude.md new file mode 100644 index 0000000..2d351a7 --- /dev/null +++ b/ai/ai-review-agent-claude.md
@@ -0,0 +1,35 @@ +# Gerrit AI Review Agent for Claude + +Implementation of Gerrit's AI Code Review Agent API on top of Anthropic's Claude. + +[Install](#install-in-gerrit) this plugin and enable the Gerrit AI chat to enjoy +a side-by-side collaboration with Claude 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-claude-1.0.groovy` script into your Gerrit site (`$GERRIT_SITE`) +plugins' directory. + +```bash +cp ai-review-agent-claude-1.0.groovy "$GERRIT_SITE/plugins/" +``` \ No newline at end of file