Provide complete file diffs to ChatGPT

The full file differences are forwarded to ChatGPT, rather than just
the revision content with a few context lines before and after the
changes. This enhances the response quality by offering comprehensive
file context.
Additionally, introduced the 'maxReviewFileSize' configuration option
to ensure only files of a certain size or smaller are reviewed.

Jira-Id: IT-103
Change-Id: I2b58884e2a2569b57cec038f8ec8157a173c8276
Signed-off-by: Patrizio <patrizio.gelosi@amarulasolutions.com>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/GerritClient.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/GerritClient.java
index f7cecce..205a29e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/GerritClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/GerritClient.java
@@ -4,6 +4,7 @@
 import com.google.gson.Gson;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.chatgpt.config.Configuration;
+import com.google.gson.reflect.TypeToken;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.http.entity.ContentType;
 
@@ -12,9 +13,8 @@
 import java.net.http.HttpRequest;
 import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
+import java.lang.reflect.Type;
 
 import static java.net.HttpURLConnection.HTTP_OK;
 
@@ -24,12 +24,14 @@
     private final Gson gson = new Gson();
     private final HttpClientWithRetry httpClientWithRetry = new HttpClientWithRetry();
 
-    public String getPatchSet(Configuration config, String fullChangeId) throws Exception {
+    private List<String> getAffectedFiles(Configuration config, String fullChangeId) throws Exception {
+        URI uri = URI.create(config.getGerritAuthBaseUrl()
+                        + UriResourceLocator.gerritPatchSetFilesUri(fullChangeId));
+        log.debug("patchSet uri: {}", uri.toString());
         HttpRequest request = HttpRequest.newBuilder()
                 .header(HttpHeaders.AUTHORIZATION, generateBasicAuth(config.getGerritUserName(),
                         config.getGerritPassword()))
-                .uri(URI.create(config.getGerritAuthBaseUrl()
-                        + UriResourceLocator.gerritPatchSetUri(fullChangeId)))
+                .uri(uri)
                 .build();
 
         HttpResponse<String> response = httpClientWithRetry.execute(request);
@@ -41,7 +43,60 @@
 
         String responseBody = response.body();
         log.info("Successfully obtained patch. Decoding response body.");
-        return new String(Base64.getDecoder().decode(responseBody));
+
+        Type listType = new TypeToken<Map<String, Map<String, String>>>() {}.getType();
+        Map<String, Map<String, String>> map = gson.fromJson(responseBody, listType);
+        List<String> files = new ArrayList<>();
+        for (Map.Entry<String, Map<String, String>> file : map.entrySet()) {
+            String filename = file.getKey();
+            if (!filename.equals("/COMMIT_MSG")) {
+                Integer size = Integer.valueOf(file.getValue().get("size"));
+                if (size > config.getMaxReviewFileSize()) {
+                    log.info("File '{}' not reviewed because its size exceeds the fixed maximum allowable size.",
+                            filename);
+                }
+                else {
+                    files.add(filename);
+                }
+            }
+        }
+
+        return files;
+    }
+
+    private String getFileDiffs(Configuration config, String fullChangeId, List<String> files) throws Exception {
+        List<String> diffs = new ArrayList<>();
+        for (String filename : files) {
+            URI uri = URI.create(config.getGerritAuthBaseUrl()
+                    + UriResourceLocator.gerritPatchSetFilesUri(fullChangeId)
+                    + UriResourceLocator.gerritDiffPostfixUri(filename));
+            log.debug("Diff uri: {}", uri.toString());
+            HttpRequest request = HttpRequest.newBuilder()
+                    .header(HttpHeaders.AUTHORIZATION, generateBasicAuth(config.getGerritUserName(),
+                            config.getGerritPassword()))
+                    .uri(uri)
+                    .build();
+
+            HttpResponse<String> response = httpClientWithRetry.execute(request);
+
+            if (response.statusCode() != HTTP_OK) {
+                log.error("Failed to get patch. Response: {}", response);
+                throw new IOException("Failed to get patch from Gerrit");
+            }
+
+            diffs.add(response.body().replaceAll("^[')\\]}']+", ""));
+        }
+        return "[" + String.join(",", diffs) + "]";
+    }
+
+    public String getPatchSet(Configuration config, String fullChangeId) throws Exception {
+        List<String> files = getAffectedFiles(config, fullChangeId);
+        log.debug("Patch files: {}", files.toString());
+
+        String fileDiffs = getFileDiffs(config, fullChangeId, files);
+        log.debug("File diffs: {}", fileDiffs);
+
+        return fileDiffs;
     }
 
     private String generateBasicAuth(String username, String password) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/UriResourceLocator.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/UriResourceLocator.java
index 6f58e28..6041726 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/UriResourceLocator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/client/UriResourceLocator.java
@@ -10,8 +10,12 @@
         return "/a";
     }
 
-    public static String gerritPatchSetUri(String fullChangeId) {
-        return "/changes/" + fullChangeId + "/revisions/current/patch";
+    public static String gerritDiffPostfixUri(String filename) {
+        return "/" + filename + "/diff";
+    }
+
+    public static String gerritPatchSetFilesUri(String fullChangeId) {
+        return "/changes/" + fullChangeId + "/revisions/current/files";
     }
 
     public static String gerritCommentUri(String fullChangeId) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/config/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/config/Configuration.java
index 13a8f1f..2a000c7 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/chatgpt/config/Configuration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/chatgpt/config/Configuration.java
@@ -11,7 +11,9 @@
 
     public static final String OPENAI_DOMAIN = "https://api.openai.com";
     public static final String DEFAULT_GPT_MODEL = "gpt-3.5-turbo";
-    public static final String DEFAULT_GPT_PROMPT = "Act as a Code Review Helper, please review this patch set: ";
+    public static final String DEFAULT_GPT_PROMPT = "Act as a Code Review Helper. Review only the \"a\" (lines " +
+            "removed) and \"b\" (lines added) items of the following diff, using the lines in the \"ab\" items as " +
+            "context. ";
     public static final String NOT_CONFIGURED_ERROR_MSG = "%s is not configured";
     public static final String KEY_GPT_PROMPT = "gptPrompt";
     private static final String DEFAULT_GPT_TEMPERATURE = "1";
@@ -21,6 +23,7 @@
     private static final boolean DEFAULT_PATCH_SET_REDUCTION = false;
     private static final boolean DEFAULT_PROJECT_ENABLE = false;
     private static final int DEFAULT_MAX_REVIEW_LINES = 1000;
+    private static final int DEFAULT_MAX_REVIEW_FILE_SIZE = 10000;
     private static final String KEY_GPT_TOKEN = "gptToken";
     private static final String KEY_GERRIT_AUTH_BASE_URL = "gerritAuthBaseUrl";
     private static final String KEY_GERRIT_USERNAME = "gerritUserName";
@@ -34,6 +37,7 @@
     private static final String KEY_ENABLED_PROJECTS = "enabledProjects";
     private static final String KEY_PATCH_SET_REDUCTION = "patchSetReduction";
     private static final String KEY_MAX_REVIEW_LINES = "maxReviewLines";
+    private static final String KEY_MAX_REVIEW_FILE_SIZE = "maxReviewFileSize";
     private final Map<String, Object> configsDynamically = Maps.newHashMap();
     private final PluginConfig globalConfig;
     private final PluginConfig projectConfig;
@@ -106,6 +110,10 @@
         return getInt(KEY_MAX_REVIEW_LINES, DEFAULT_MAX_REVIEW_LINES);
     }
 
+    public int getMaxReviewFileSize() {
+        return getInt(KEY_MAX_REVIEW_FILE_SIZE, DEFAULT_MAX_REVIEW_FILE_SIZE);
+    }
+
     private String getValidatedOrThrow(String key) {
         String value = projectConfig.getString(key);
         if (value == null) {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewTest.java b/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewTest.java
index f1254e4..71fccb3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/chatgpt/ChatGptReviewTest.java
@@ -36,7 +36,8 @@
 import java.util.concurrent.ExecutionException;
 
 import static com.googlesource.gerrit.plugins.chatgpt.client.UriResourceLocator.gerritCommentUri;
-import static com.googlesource.gerrit.plugins.chatgpt.client.UriResourceLocator.gerritPatchSetUri;
+import static com.googlesource.gerrit.plugins.chatgpt.client.UriResourceLocator.gerritDiffPostfixUri;
+import static com.googlesource.gerrit.plugins.chatgpt.client.UriResourceLocator.gerritPatchSetFilesUri;
 import static com.googlesource.gerrit.plugins.chatgpt.listener.EventListenerHandler.buildFullChangeId;
 import static java.net.HttpURLConnection.HTTP_OK;
 import static org.mockito.Mockito.mock;
@@ -67,17 +68,26 @@
         when(config.getGptTemperature()).thenReturn(1.0);
         when(config.getGptStreamOutput()).thenReturn(true);
         when(config.getMaxReviewLines()).thenReturn(500);
+        when(config.getMaxReviewFileSize()).thenReturn(10000);
         when(config.getEnabledProjects()).thenReturn("");
         when(config.isProjectEnable()).thenReturn(true);
     }
 
     private void setupMockRequests() {
-        // Mocks the behavior of the getPatchSet request
-        WireMock.stubFor(WireMock.get(gerritPatchSetUri(buildFullChangeId(PROJECT_NAME, BRANCH_NAME, CHANGE_ID)))
+        // Mocks the behavior of the gerritPatchSetFiles request
+        WireMock.stubFor(WireMock.get(gerritPatchSetFilesUri(buildFullChangeId(PROJECT_NAME, BRANCH_NAME, CHANGE_ID)))
                 .willReturn(WireMock.aResponse()
                         .withStatus(HTTP_OK)
                         .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
-                        .withBody(Base64.getEncoder().encodeToString("myPatch".getBytes()))));
+                        .withBodyFile("gerritPatchSetFiles.json")));
+
+        // Mocks the behavior of the gerritPatchSet diff request
+        WireMock.stubFor(WireMock.get(gerritPatchSetFilesUri(buildFullChangeId(PROJECT_NAME, BRANCH_NAME, CHANGE_ID)) +
+                            gerritDiffPostfixUri("test_file.py"))
+                .willReturn(WireMock.aResponse()
+                        .withStatus(HTTP_OK)
+                        .withHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
+                        .withBodyFile("gerritPatchSetDiff.json")));
 
         // Mocks the behavior of the askGpt request
         byte[] gptAnswer = Base64.getDecoder().decode("ZGF0YTogeyJpZCI6ImNoYXRjbXBsLTdSZDVOYVpEOGJNVTRkdnBVV2" +
diff --git a/src/test/resources/__files/gerritPatchSetDiff.json b/src/test/resources/__files/gerritPatchSetDiff.json
new file mode 100644
index 0000000..a14a217
--- /dev/null
+++ b/src/test/resources/__files/gerritPatchSetDiff.json
@@ -0,0 +1,55 @@
+{
+  "meta_a": {
+    "name": "test_file.py",
+    "content_type": "text/x-python",
+    "lines": 42
+  },
+  "meta_b": {
+    "name": "test_file.py",
+    "content_type": "text/x-python",
+    "lines": 42
+  },
+  "change_type": "MODIFIED",
+  "diff_header": [
+    "diff --git a/test_file.py b/test_file.py",
+    "index 3af5d07..0c975d6 100644",
+    "--- a/test_file.py",
+    "+++ b/test_file.py"
+  ],
+  "content": [
+    {
+      "ab": [
+        "from typing import Any, Callable, Type, Union",
+        "",
+        "__all__ = [\"importclass\", \"preprocess_classes\", \"TypeClassOrPath\"]",
+        "",
+        "TypeClassOrPath = Union[Type, str]",
+        "",
+        "",
+        "def importclass(",
+        "    module_name: str,",
+        "    class_name: Union[str, None] = None",
+        ") -> Type:",
+        "    \"\"\"",
+        "    Dynamically import a class from a specified module.",
+        "",
+        "    :param module_name: The name of the module to import.",
+        "    :param class_name: The name of the class in the module to import. Defaults to None.",
+        "    :return: The dynamically imported class.",
+        "    \"\"\"",
+        "    if not class_name:",
+        "        module_name, class_name = module_name.rsplit('.', 1)",
+        "    loaded_module = __import__(module_name, fromlist=[class_name])"
+      ]
+    },
+    {
+      "a": [
+        "        return getattr(loaded_module, class_name)"
+      ],
+      "b": [
+        "            return getattr(loaded_module, class_name)"
+      ],
+      "common": true
+    }
+  ]
+}
\ No newline at end of file
diff --git a/src/test/resources/__files/gerritPatchSetFiles.json b/src/test/resources/__files/gerritPatchSetFiles.json
new file mode 100644
index 0000000..40885b5
--- /dev/null
+++ b/src/test/resources/__files/gerritPatchSetFiles.json
@@ -0,0 +1,16 @@
+{
+  "/COMMIT_MSG": {
+    "status": "A",
+    "lines_inserted": 9,
+    "size_delta": 355,
+    "size": 355
+  },
+  "test_file.py":{
+    "old_mode":33188,
+    "new_mode":33188,
+    "lines_inserted":1,
+    "lines_deleted":1,
+    "size_delta":11,
+    "size":6737
+  }
+}
\ No newline at end of file