Add a plugin API for AI Chat Release-Notes: skip Google-Bug-Id: b/437944720 Change-Id: I6bf89a58ccf638649d3eb6d8642c2b9d138f245e
diff --git a/polygerrit-ui/app/api/ai-code-review.ts b/polygerrit-ui/app/api/ai-code-review.ts new file mode 100644 index 0000000..c3148a6 --- /dev/null +++ b/polygerrit-ui/app/api/ai-code-review.ts
@@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {ChangeInfo} from './rest-api'; + +export declare interface AiCodeReviewPluginApi { + /** + * Must only be called once. You cannot register twice (throws an error). + * You cannot unregister. + */ + register(provider: AiCodeReviewProvider): void; +} + +export declare interface ChatRequest { + /** + * The prompt to be sent to the LLM. + */ + prompt: string; + /** + * UUID of the conversation. To start a new conversation, the caller should + * generate a new UUID. + * To continue an existing conversation, the caller should provide the UUID of + * the conversation. + */ + conversationId: string; + /** + * Plugins can choose what context they want to derive from the change and + * send along to their backends. `changeInfo` contains broadly all the + * information about the change, and the plugin can also make additional + * requests to the REST API (e.g. getting patch content) by using properties + * from the change info. + */ + changeInfo: ChangeInfo; +} + +/** + * The chat response may come in as a stream, so instead of just one response + * object this listener will get multiple calls until the response is completed. + */ +export declare interface ChatResponseListener { + /** + * Emits one piece of a streaming text response from the backend, to be + * interpreted as markdown by the web app and to be shown as is to the user. + */ + emitText(text: string): void; + /** + * Emits an error message, indicating that the turn has failed. Will be + * immediately followed by a done() call. + */ + emitError(error: string): void; + /** + * The turn is completed. The listener can be discarded. + */ + done(): void; +} + +export declare interface AiCodeReviewProvider { + /** + * If a AiCodeReviewProvider provider is registered that implements this + * method, then Gerrit will offer a side panel for the user to have an AI + * Chat conversation. Each chat() call is one turn of such a conversation. + */ + chat?(req: ChatRequest, listener: ChatResponseListener): void; +}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts index 6f6dfb6..934c87cd 100644 --- a/polygerrit-ui/app/api/plugin.ts +++ b/polygerrit-ui/app/api/plugin.ts
@@ -18,6 +18,7 @@ import {StylePluginApi} from './styles'; import {SuggestionsPluginApi} from './suggestions'; import {ChangeUpdatesPluginApi} from './change-updates'; +import {AiCodeReviewPluginApi} from './ai-code-review'; export enum TargetElement { CHANGE_ACTIONS = 'changeactions', @@ -65,6 +66,7 @@ */ url(): string; admin(): AdminPluginApi; + aiCodeReview(): AiCodeReviewPluginApi; annotationApi(): AnnotationPluginApi; attributeHelper(element: Element): AttributeHelperPluginApi; changeActions(): ChangeActionsPluginApi;
diff --git a/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts new file mode 100644 index 0000000..dd7db5a --- /dev/null +++ b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
@@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {PluginApi} from '../../../api/plugin'; +import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; +import {PluginsModel} from '../../../models/plugins/plugins-model'; +import { + AiCodeReviewPluginApi, + AiCodeReviewProvider, +} from '../../../api/ai-code-review'; + +enum State { + NOT_REGISTERED, + REGISTERED, +} + +/** + * Plugin API for AI Code Review. + * + * This object is returned to plugins that want to provide AI Code Review data. + * Plugins normally just call register() once at startup and then wait for + * calls on the provider interface. + */ +export class GrAiCodeReviewApi implements AiCodeReviewPluginApi { + private state = State.NOT_REGISTERED; + + constructor( + private readonly reporting: ReportingService, + private readonly pluginsModel: PluginsModel, + readonly plugin: PluginApi + ) { + this.reporting.trackApi(this.plugin, 'ai-code-review', 'constructor'); + } + + register(provider: AiCodeReviewProvider): void { + this.reporting.trackApi(this.plugin, 'ai-code-review', 'register'); + if (this.state === State.REGISTERED) { + throw new Error('Only one provider can be registered per plugin.'); + } + this.state = State.REGISTERED; + this.pluginsModel.aiCodeReviewRegister({ + pluginName: this.plugin.getPluginName(), + provider, + }); + } +}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts index 35c93e6..2a46546 100644 --- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts +++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -37,6 +37,7 @@ import {StylePluginApi} from '../../../api/styles'; import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api'; import {GrChangeUpdatesApi} from '../../plugins/gr-change-updates-api/gr-change-updates-api'; +import {GrAiCodeReviewApi} from '../../plugins/gr-ai-code-review-api/gr-ai-code-review-api'; const PLUGIN_NAME_NOT_SET = 'NULL'; @@ -179,6 +180,10 @@ return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`; } + aiCodeReview(): GrAiCodeReviewApi { + return new GrAiCodeReviewApi(this.report, this.pluginsModel, this); + } + annotationApi(): AnnotationPluginApi { return new GrAnnotationActionsInterface( this.report,
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts index 9df61bc..3ce60da 100644 --- a/polygerrit-ui/app/models/plugins/plugins-model.ts +++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -15,6 +15,7 @@ import {CoverageProvider, TokenHoverListener} from '../../api/annotation'; import {SuggestionsProvider} from '../../api/suggestions'; import {ChangeUpdatesPublisher} from '../../api/change-updates'; +import {AiCodeReviewProvider} from '../../api/ai-code-review'; export interface CoveragePlugin { pluginName: string; @@ -32,6 +33,11 @@ publisher: ChangeUpdatesPublisher; } +export interface AiCodeReviewPlugin { + pluginName: string; + provider: AiCodeReviewProvider; +} + export interface SuggestionPlugin { pluginName: string; provider: SuggestionsProvider; @@ -70,6 +76,11 @@ checksPlugins: ChecksPlugin[]; /** + * List of plugins that have called aiCodeReview().register(). + */ + aiCodeReviewPlugins: AiCodeReviewPlugin[]; + + /** * List of plugins that have called suggestions().register(). */ suggestionsPlugins: SuggestionPlugin[]; @@ -105,6 +116,11 @@ state => state.changeUpdatesPlugins ); + public aiCodeReviewPlugins$ = select( + this.state$, + state => state.aiCodeReviewPlugins + ); + public suggestionsPlugins$ = select( this.state$, state => state.suggestionsPlugins @@ -118,6 +134,7 @@ coveragePlugins: [], changeUpdatesPlugins: [], checksPlugins: [], + aiCodeReviewPlugins: [], suggestionsPlugins: [], tokenHighlightPlugins: [], }); @@ -175,6 +192,22 @@ this.setState(nextState); } + aiCodeReviewRegister(plugin: AiCodeReviewPlugin) { + const nextState = {...this.getState()}; + nextState.aiCodeReviewPlugins = [...nextState.aiCodeReviewPlugins]; + const alreadyRegistered = nextState.aiCodeReviewPlugins.some( + p => p.pluginName === plugin.pluginName + ); + if (alreadyRegistered) { + console.warn( + `${plugin.pluginName} tried to register twice as a AI Code Review provider. Ignored.` + ); + return; + } + nextState.aiCodeReviewPlugins.push(plugin); + this.setState(nextState); + } + suggestionsRegister(plugin: SuggestionPlugin) { const nextState = {...this.getState()}; nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];