Support full-cycle stateful review requests

Full-cycle stateful patch-set review requests are now supported through
the implementation of the ChatGPT Run layer. Once the initial message is
committed to a new ChatGPT thread, a ChatGPT run is created and linked
to that thread.
This run is then polled at regular intervals (default: 1 second) until
completion. Subsequently, the steps of the run are retrieved along with
the ChatGPT replies, which are processed similarly to the stateless
mode.
Currently, the following stateless features are not yet supported in
stateful mode:
- Review of commit messages
- Inline code lookup
- Detection of repeated replies

Change-Id: I84f7087d838f0cec9bc55c8e724519db4f17df72
Signed-off-by: Patrizio <patrizio.gelosi@amarulasolutions.com>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/common/client/http/HttpClient.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/common/client/http/HttpClient.java
index 85abe94..4436d9e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/common/client/http/HttpClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/common/client/http/HttpClient.java
@@ -29,11 +29,17 @@
     }
 
     public Request createRequest(String uri, String bearer, RequestBody body, Map<String, String> additionalHeaders) {
+        // If body is null, a GET request is initiated. Otherwise, a POST request is sent with the specified body.
         Request.Builder builder = new Request.Builder()
                 .url(uri)
-                .header("Authorization", "Bearer " + bearer)
-                .post(body);
+                .header("Authorization", "Bearer " + bearer);
 
+        if (body != null) {
+            builder.post(body);
+        }
+        else {
+            builder.get();
+        }
         if (additionalHeaders != null) {
             for (Map.Entry<String, String> header : additionalHeaders.entrySet()) {
                 builder.header(header.getKey(), header.getValue());
@@ -42,17 +48,18 @@
         return builder.build();
     }
 
-    public Request createRequest(String uri, String bearer, RequestBody body) {
-        return createRequest(uri, bearer, body, null);
-    }
-
     public Request createRequestFromJson(String uri, String bearer, Object requestObject,
                                          Map<String, String> additionalHeaders) {
-        String bodyJson = getGson().toJson(requestObject);
-        log.debug("Request body: {}", bodyJson);
-        RequestBody body = RequestBody.create(bodyJson, MediaType.get("application/json"));
+        if (requestObject != null) {
+            String bodyJson = getGson().toJson(requestObject);
+            log.debug("Request body: {}", bodyJson);
+            RequestBody body = RequestBody.create(bodyJson, MediaType.get("application/json"));
 
-        return createRequest(uri, bearer, body, additionalHeaders);
+            return createRequest(uri, bearer, body, additionalHeaders);
+        }
+        else {
+            return createRequest(uri, bearer, null, additionalHeaders);
+        }
     }
 
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/UriResourceLocatorStateful.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/UriResourceLocatorStateful.java
index ed152bd..59246b6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/UriResourceLocatorStateful.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/UriResourceLocatorStateful.java
@@ -20,4 +20,17 @@
     public static String threadMessagesUri(String threadId) {
         return threadRetrieveUri(threadId) + "/messages";
     }
+
+    public static String runsUri(String threadId) {
+        return threadRetrieveUri(threadId) + "/runs";
+    }
+
+    public static String runRetrieveUri(String threadId, String runId) {
+        return runsUri(threadId) + "/" + runId;
+    }
+
+    public static String runStepsUri(String threadId, String runId) {
+        return runRetrieveUri(threadId, runId) + "/steps";
+    }
+
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
index 0cf3af8..4f46205 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptClientStateful.java
@@ -32,8 +32,11 @@
         String threadId = chatGptThread.createThread();
         chatGptThread.addMessage();
 
-        // Placeholder implementation, change to actual logic later.
-        throw new UnsupportedOperationException("Method not implemented yet.");
+        ChatGptRun chatGptRun = new ChatGptRun(threadId, config, pluginDataHandler);
+        chatGptRun.createRun();
+        chatGptRun.pollRun();
+
+        return getResponseContent(chatGptRun.getFirstStep());
     }
 
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java
new file mode 100644
index 0000000..4a2160e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/client/api/chatgpt/ChatGptRun.java
@@ -0,0 +1,109 @@
+package com.googlesource.gerrit.plugins.chatgpt.mode.stateful.client.api.chatgpt;
+
+import com.googlesource.gerrit.plugins.chatgpt.config.Configuration;
+import com.googlesource.gerrit.plugins.chatgpt.data.PluginDataHandler;
+import com.googlesource.gerrit.plugins.chatgpt.mode.common.client.ClientBase;
+import com.googlesource.gerrit.plugins.chatgpt.mode.common.model.api.chatgpt.ChatGptToolCall;
+import com.googlesource.gerrit.plugins.chatgpt.mode.stateful.client.api.UriResourceLocatorStateful;
+import com.googlesource.gerrit.plugins.chatgpt.mode.stateful.model.api.chatgpt.*;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.Request;
+
+import java.net.URI;
+import java.util.*;
+
+import static com.googlesource.gerrit.plugins.chatgpt.mode.stateful.client.api.chatgpt.ChatGptAssistant.KEY_ASSISTANT_ID;
+import static com.googlesource.gerrit.plugins.chatgpt.utils.GsonUtils.getGson;
+
+@Slf4j
+public class ChatGptRun extends ClientBase {
+    private static final int RUN_POLLING_INTERVAL = 1000;
+    private static final Set<String> UNCOMPLETED_STATUSES = new HashSet<>(Arrays.asList(
+            "queued",
+            "in_progress",
+            "cancelling"
+    ));
+    public static final String COMPLETED_STATUS = "completed";
+
+    private final ChatGptHttpClient httpClient = new ChatGptHttpClient();
+    private final String threadId;
+    private final PluginDataHandler pluginDataHandler;
+
+    private ChatGptResponse runResponse;
+    private ChatGptListResponse stepResponse;
+
+    public ChatGptRun(String threadId, Configuration config, PluginDataHandler pluginDataHandler) {
+        super(config);
+        this.threadId = threadId;
+        this.pluginDataHandler = pluginDataHandler;
+    }
+
+    public void createRun() {
+        Request request = runCreateRequest();
+        log.info("ChatGPT Create Run request: {}", request);
+
+        runResponse = getGson().fromJson(httpClient.execute(request), ChatGptResponse.class);
+        log.info("Run created: {}", runResponse);
+    }
+
+    public void pollRun() {
+        int pollingCount = 0;
+
+        while (UNCOMPLETED_STATUSES.contains(runResponse.getStatus())) {
+            pollingCount++;
+            log.debug("Polling request #{}", pollingCount);
+            try {
+                Thread.sleep(RUN_POLLING_INTERVAL);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException("Thread was interrupted", e);
+            }
+            Request pollRequest = getPollRequest();
+            log.debug("ChatGPT Poll Run request: {}", pollRequest);
+            runResponse = getGson().fromJson(httpClient.execute(pollRequest), ChatGptResponse.class);
+            log.debug("ChatGPT Run response: {}", runResponse);
+        }
+        Request stepsRequest = getStepsRequest();
+        log.debug("ChatGPT Retrieve Run Steps request: {}", stepsRequest);
+
+        String response = httpClient.execute(stepsRequest);
+        stepResponse = getGson().fromJson(response, ChatGptListResponse.class);
+        log.info("Run executed after {} polling requests: {}", pollingCount, stepResponse);
+    }
+
+    public List<ChatGptToolCall> getFirstStep() {
+        return stepResponse.getData().get(0).getStepDetails().getToolCalls();
+    }
+
+    private Request runCreateRequest() {
+        URI uri = URI.create(config.getGptDomain() + UriResourceLocatorStateful.runsUri(threadId));
+        log.debug("ChatGPT Create Run request URI: {}", uri);
+
+        ChatGptCreateRunRequest requestBody = ChatGptCreateRunRequest.builder()
+                .assistantId(pluginDataHandler.getValue(KEY_ASSISTANT_ID))
+                .build();
+
+        return httpClient.createRequestFromJson(uri.toString(), config.getGptToken(), requestBody);
+    }
+
+    private Request getPollRequest() {
+        URI uri = URI.create(config.getGptDomain()
+                + UriResourceLocatorStateful.runRetrieveUri(threadId, runResponse.getId()));
+        log.debug("ChatGPT Poll Run request URI: {}", uri);
+
+        return getRunPollRequest(uri);
+    }
+
+    private Request getStepsRequest() {
+        URI uri = URI.create(config.getGptDomain()
+                + UriResourceLocatorStateful.runStepsUri(threadId, runResponse.getId()));
+        log.debug("ChatGPT Run Steps request URI: {}", uri);
+
+        return getRunPollRequest(uri);
+    }
+
+    private Request getRunPollRequest(URI uri) {
+        return httpClient.createRequestFromJson(uri.toString(), config.getGptToken(), null);
+    }
+
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptCreateRunRequest.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptCreateRunRequest.java
new file mode 100644
index 0000000..bd3f7eb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptCreateRunRequest.java
@@ -0,0 +1,12 @@
+package com.googlesource.gerrit.plugins.chatgpt.mode.stateful.model.api.chatgpt;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class ChatGptCreateRunRequest {
+    @SerializedName("assistant_id")
+    private String assistantId;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptListResponse.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptListResponse.java
new file mode 100644
index 0000000..0c0ccd4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptListResponse.java
@@ -0,0 +1,11 @@
+package com.googlesource.gerrit.plugins.chatgpt.mode.stateful.model.api.chatgpt;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class ChatGptListResponse {
+    private String object;
+    private List<ChatGptRunStepsResponse> data;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptRunStepsResponse.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptRunStepsResponse.java
new file mode 100644
index 0000000..d951b1d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/mode/stateful/model/api/chatgpt/ChatGptRunStepsResponse.java
@@ -0,0 +1,13 @@
+package com.googlesource.gerrit.plugins.chatgpt.mode.stateful.model.api.chatgpt;
+
+import com.google.gson.annotations.SerializedName;
+import com.googlesource.gerrit.plugins.chatgpt.mode.common.model.api.chatgpt.ChatGptResponseMessage;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class ChatGptRunStepsResponse extends ChatGptResponse {
+    @SerializedName("step_details")
+    private ChatGptResponseMessage stepDetails;
+}