Merge "Add a new plugin API for comment autocompletion"
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index b4158be..c3089a9 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -27,7 +27,21 @@
   lineNumber?: number;
 }
 
+export declare interface AutocompleteCommentRequest {
+  id: string;
+  commentText: string;
+  changeInfo: ChangeInfo;
+  patchsetNumber: RevisionPatchSetNum;
+  filePath: string;
+  range?: CommentRange;
+  lineNumber?: number;
+}
+
 export declare interface SuggestionsProvider {
+  autocompleteComment?(
+    req: AutocompleteCommentRequest
+  ): Promise<AutocompleteCommentResponse>;
+
   /**
    * Gerrit calls these methods when ...
    * - ... user types a comment draft
@@ -55,6 +69,11 @@
   supportedFileExtensions?: string[];
 }
 
+export declare interface AutocompleteCommentResponse {
+  responseCode: ResponseCode;
+  completion?: string;
+}
+
 export declare interface SuggestCodeResponse {
   responseCode: ResponseCode;
   suggestions: Suggestion[];
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 3509146..254845e 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -93,6 +93,7 @@
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
+export const AUTOCOMPLETE_DEBOUNCE_DELAY_MS = 200;
 export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
   'enableGenerateSuggestionStorageKeyForCommentWithId-';
 
@@ -219,6 +220,12 @@
   @state()
   messageText = '';
 
+  /**
+   * An hint for autocompleting the comment message from plugin suggestion
+   * providers.
+   */
+  @state() autocompleteHint = '';
+
   /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
   @state()
   unresolved = true;
@@ -300,6 +307,12 @@
   private generateSuggestionTrigger$ = new Subject();
 
   /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autocompleteComment().
+   */
+  private autocompleteTrigger$ = new Subject();
+
+  /**
    * Set to the content of DraftInfo when entering editing mode.
    * Only used for "Cancel".
    */
@@ -392,6 +405,23 @@
       () => this.getConfigModel().docsBaseUrl$,
       docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
     );
+    subscribe(
+      this,
+      () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
+      // We currently support results from only 1 provider.
+      suggestionsPlugins =>
+        (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+    );
+    subscribe(
+      this,
+      () =>
+        this.autocompleteTrigger$.pipe(
+          debounceTime(AUTOCOMPLETE_DEBOUNCE_DELAY_MS)
+        ),
+      () => {
+        this.autocompleteComment();
+      }
+    );
     if (
       this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
       this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
@@ -424,15 +454,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const suggestionsPlugins =
-          this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
-        // We currently support results from only 1 provider.
-        this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
-      });
-
     if (this.comment?.id) {
       const generateSuggestionStoredContent =
         this.getStorage().getEditableContentItem(
@@ -872,14 +893,19 @@
         rows="4"
         .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
+        autocompleteHint=${this.autocompleteHint}
         @text-changed=${(e: ValueChangedEvent) => {
           // TODO: This is causing a re-render of <gr-comment> on every key
           // press. Try to avoid always setting `this.messageText` or at least
           // debounce it. Most of the code can just inspect the current value
           // of the textare instead of needing a dedicated property.
           this.messageText = e.detail.value;
+          // As soon as the user changes the next the hint for autocompletion
+          // is invalidated.
+          this.autocompleteHint = '';
           this.autoSaveTrigger$.next();
           this.generateSuggestionTrigger$.next();
+          this.autocompleteTrigger$.next();
         }}
       ></gr-suggestion-textarea>
     `;
@@ -1286,6 +1312,39 @@
     }
   }
 
+  private async autocompleteComment() {
+    const enabled = this.flagsService.isEnabled(
+      KnownExperimentId.COMMENT_AUTOCOMPLETION
+    );
+    const suggestionsProvider = this.suggestionsProvider;
+    const change = this.getChangeModel().getChange();
+    if (
+      !enabled ||
+      !suggestionsProvider?.autocompleteComment ||
+      !change ||
+      !this.comment?.patch_set ||
+      !this.comment.path ||
+      this.messageText.length === 0
+    ) {
+      return;
+    }
+    const commentText = this.messageText;
+    const response = await suggestionsProvider.autocompleteComment({
+      id: id(this.comment),
+      commentText,
+      changeInfo: change as ChangeInfo,
+      patchsetNumber: this.comment?.patch_set,
+      filePath: this.comment.path,
+      range: this.comment.range,
+      lineNumber: this.comment.line,
+    });
+    // If between request and response the user has changed the message, then
+    // ignore the suggestion for the old message text.
+    if (this.messageText !== commentText) return;
+    if (!response?.completion) return;
+    this.autocompleteHint = response.completion;
+  }
+
   private renderRobotActions() {
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 701052d..b748544 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -408,6 +408,7 @@
               <div class="body">
                 <gr-suggestion-textarea
                   autocomplete="on"
+                  autocompletehint=""
                   class="code editMessage"
                   code=""
                   id="editTextarea"
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 04f66a7..2d22f6a 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -143,6 +143,7 @@
       .placeholder=${this.el.placeholder}
       ?disabled=${this.el.disabled}
       .value=${this.el.text}
+      .hint=${this.el.autocompleteHint}
       @input=${(e: InputEvent) => {
         const value = (e.target as GrTextarea).value;
         this.el.text = value ?? '';
@@ -227,6 +228,12 @@
     standard monospace font. */
   @property({type: Boolean}) code = false;
 
+  /**
+   * An autocompletion hint that is passed to <gr-textarea>, which will allow\
+   * the user to accept it by pressing tab.
+   */
+  @property({type: String}) autocompleteHint = '';
+
   @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
   // Accessed in tests.
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8e9bead..880bcd0 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -89,6 +89,11 @@
 
   public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
 
+  public suggestionsPlugins$ = select(
+    this.state$,
+    state => state.suggestionsPlugins
+  );
+
   public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
 
   constructor() {