diff --git a/pom.xml b/pom.xml index 6a6dc21..bfe96b5 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,10 @@ com.fasterxml.jackson.core jackson-core + + io.micrometer + micrometer-registry-prometheus + diff --git a/src/main/java/com/example/blast_radius/infra/GroqApiException.java b/src/main/java/com/example/blast_radius/infra/GroqApiException.java new file mode 100644 index 0000000..3350af2 --- /dev/null +++ b/src/main/java/com/example/blast_radius/infra/GroqApiException.java @@ -0,0 +1,29 @@ +package com.example.blast_radius.infra; + +/** + * Thrown when the Groq API call fails due to network errors, HTTP errors, + * or an unparseable response envelope. + */ +public class GroqApiException extends Exception { + + private final int httpStatus; + + public GroqApiException(String message) { + super(message); + this.httpStatus = -1; + } + + public GroqApiException(String message, int httpStatus) { + super(message); + this.httpStatus = httpStatus; + } + + public GroqApiException(String message, Throwable cause) { + super(message, cause); + this.httpStatus = -1; + } + + public int getHttpStatus() { + return httpStatus; + } +} diff --git a/src/main/java/com/example/blast_radius/infra/GroqClient.java b/src/main/java/com/example/blast_radius/infra/GroqClient.java index becc870..59c586d 100644 --- a/src/main/java/com/example/blast_radius/infra/GroqClient.java +++ b/src/main/java/com/example/blast_radius/infra/GroqClient.java @@ -5,11 +5,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import java.util.HashMap; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; @Component @@ -17,7 +20,11 @@ public class GroqClient { private static final Logger log = LoggerFactory.getLogger(GroqClient.class); - private final RestTemplate restTemplate; + private static final int MAX_ATTEMPTS = 2; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(20); + + private final HttpClient httpClient; private final ObjectMapper objectMapper; @Value("${groq.api.key}") @@ -30,59 +37,108 @@ public class GroqClient { private String model; public GroqClient() { - // TODO: Configure timeouts on RestTemplate (e.g., 30s connect, 60s read) - // SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); - // factory.setConnectTimeout(Duration.ofSeconds(30)); - // factory.setReadTimeout(Duration.ofSeconds(60)); - // this.restTemplate = new RestTemplate(factory); - this.restTemplate = new RestTemplate(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); this.objectMapper = new ObjectMapper(); } /** * Sends a prompt to the Groq chat completions API and returns the assistant's * message content as a raw string. + * Retries up to MAX_ATTEMPTS on network errors or 5xx responses. + * Does not retry on 4xx (client errors). */ - public String callChatApi(String promptPayload) throws Exception { - Map body = new HashMap<>(); - body.put("model", model); - body.put("messages", new Object[]{ - Map.of("role", "user", "content", promptPayload) - }); - body.put("response_format", Map.of("type", "json_object")); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(apiKey); - - HttpEntity> entity = new HttpEntity<>(body, headers); - - log.debug("Calling Groq API at {} with model {}", apiUrl, model); - - ResponseEntity response = - restTemplate.exchange(apiUrl, HttpMethod.POST, entity, String.class); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new RuntimeException("Groq API returned status " + response.getStatusCode()); + public String callChatApi(String promptPayload) throws GroqApiException { + String requestBody = buildRequestBody(promptPayload); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .timeout(READ_TIMEOUT) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + IOException lastIoException = null; + int lastStatusCode = -1; + + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + HttpResponse response = httpClient.send(request, + HttpResponse.BodyHandlers.ofString()); + + int status = response.statusCode(); + + if (status >= 200 && status < 300) { + return extractContent(response.body()); + } + + // 4xx — client error, do not retry + if (status >= 400 && status < 500) { + throw new GroqApiException( + "Groq API client error (HTTP " + status + ")", status); + } + + // 5xx — server error, retry if attempts remain + lastStatusCode = status; + log.warn("Groq API returned HTTP {} (attempt {}/{})", status, attempt, MAX_ATTEMPTS); + + } catch (IOException e) { + lastIoException = e; + log.warn("Groq API network error on attempt {}/{}: {}", + attempt, MAX_ATTEMPTS, e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new GroqApiException("Groq API call interrupted", e); + } } - String responseBody = response.getBody(); - if (responseBody == null || responseBody.isBlank()) { - throw new RuntimeException("Groq API returned empty response body"); + // All attempts exhausted + if (lastIoException != null) { + throw new GroqApiException( + "Groq API failed after " + MAX_ATTEMPTS + " attempts: " + lastIoException.getMessage(), + lastIoException); } + throw new GroqApiException( + "Groq API returned HTTP " + lastStatusCode + " after " + MAX_ATTEMPTS + " attempts", + lastStatusCode); + } - JsonNode root = objectMapper.readTree(responseBody); - JsonNode choices = root.path("choices"); - - if (!choices.isArray() || choices.isEmpty()) { - throw new RuntimeException("Groq API response contained no choices"); + private String buildRequestBody(String prompt) { + try { + Map body = Map.of( + "model", model, + "messages", new Object[]{ + Map.of("role", "user", "content", prompt) + }, + "response_format", Map.of("type", "json_object") + ); + return objectMapper.writeValueAsString(body); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize Groq request body", e); } + } - String content = choices.get(0).path("message").path("content").asText(); - if (content == null || content.isBlank()) { - throw new RuntimeException("Groq API returned empty message content"); + private String extractContent(String responseBody) throws GroqApiException { + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode choices = root.path("choices"); + + if (!choices.isArray() || choices.isEmpty()) { + throw new GroqApiException("Groq API response contained no choices"); + } + + String content = choices.get(0).path("message").path("content").asText(); + if (content == null || content.isBlank()) { + throw new GroqApiException("Groq API returned empty message content"); + } + + return content; + } catch (GroqApiException e) { + throw e; + } catch (Exception e) { + throw new GroqApiException("Failed to parse Groq API response structure", e); } - - return content; } } diff --git a/src/main/java/com/example/blast_radius/model/OverallRisk.java b/src/main/java/com/example/blast_radius/model/OverallRisk.java new file mode 100644 index 0000000..0b6b0e7 --- /dev/null +++ b/src/main/java/com/example/blast_radius/model/OverallRisk.java @@ -0,0 +1,39 @@ +package com.example.blast_radius.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Canonical risk levels returned by the analysis pipeline. + * LOW/MEDIUM/HIGH come from a successful LLM analysis. + * PARSING_ERROR and ERROR_UPSTREAM represent failure modes. + */ +public enum OverallRisk { + + LOW, + MEDIUM, + HIGH, + PARSING_ERROR, + ERROR_UPSTREAM; + + @JsonValue + public String toJson() { + return name(); + } + + /** + * Deserializes from the LLM's JSON string. + * Unrecognized values map to PARSING_ERROR rather than throwing. + */ + @JsonCreator + public static OverallRisk fromJson(String value) { + if (value == null || value.isBlank()) { + return PARSING_ERROR; + } + try { + return valueOf(value.strip().toUpperCase()); + } catch (IllegalArgumentException e) { + return PARSING_ERROR; + } + } +} diff --git a/src/main/java/com/example/blast_radius/model/PrAnalysisResponse.java b/src/main/java/com/example/blast_radius/model/PrAnalysisResponse.java index 927927e..e23e848 100644 --- a/src/main/java/com/example/blast_radius/model/PrAnalysisResponse.java +++ b/src/main/java/com/example/blast_radius/model/PrAnalysisResponse.java @@ -1,17 +1,23 @@ package com.example.blast_radius.model; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.util.List; public class PrAnalysisResponse { - private String overallRisk; + + private OverallRisk overallRisk; private List impactAreas; private List suggestedTests; - public String getOverallRisk() { + @JsonInclude(JsonInclude.Include.NON_NULL) + private String analysisId; + + public OverallRisk getOverallRisk() { return overallRisk; } - public void setOverallRisk(String overallRisk) { + public void setOverallRisk(OverallRisk overallRisk) { this.overallRisk = overallRisk; } @@ -30,4 +36,22 @@ public List getSuggestedTests() { public void setSuggestedTests(List suggestedTests) { this.suggestedTests = suggestedTests; } + + public String getAnalysisId() { + return analysisId; + } + + public void setAnalysisId(String analysisId) { + this.analysisId = analysisId; + } + + /** Factory for error/failure responses. */ + public static PrAnalysisResponse error(OverallRisk risk, String analysisId) { + PrAnalysisResponse r = new PrAnalysisResponse(); + r.setOverallRisk(risk); + r.setImpactAreas(List.of()); + r.setSuggestedTests(List.of()); + r.setAnalysisId(analysisId); + return r; + } } diff --git a/src/main/java/com/example/blast_radius/service/AnalysisService.java b/src/main/java/com/example/blast_radius/service/AnalysisService.java index 129d025..0006f59 100644 --- a/src/main/java/com/example/blast_radius/service/AnalysisService.java +++ b/src/main/java/com/example/blast_radius/service/AnalysisService.java @@ -1,52 +1,100 @@ package com.example.blast_radius.service; +import com.example.blast_radius.infra.GroqApiException; import com.example.blast_radius.infra.GroqClient; +import com.example.blast_radius.model.OverallRisk; import com.example.blast_radius.model.PrAnalysisRequest; import com.example.blast_radius.model.PrAnalysisResponse; +import com.example.blast_radius.util.DiffPrioritizer; import com.example.blast_radius.util.JsonParserUtil; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import java.util.List; +import java.util.UUID; @Service public class AnalysisService { private static final Logger log = LoggerFactory.getLogger(AnalysisService.class); - // Max diff size sent to the LLM (32 KB). Larger diffs are truncated. private static final int MAX_DIFF_LENGTH = 32_768; private final GroqClient groqClient; + private final Counter totalCounter; + private final MeterRegistry meterRegistry; - public AnalysisService(GroqClient groqClient) { + public AnalysisService(GroqClient groqClient, MeterRegistry meterRegistry) { this.groqClient = groqClient; + this.meterRegistry = meterRegistry; + this.totalCounter = Counter.builder("blast_radius.analyses.total") + .description("Total number of PR analyses attempted") + .register(meterRegistry); } public PrAnalysisResponse analyze(PrAnalysisRequest request) { + String analysisId = UUID.randomUUID().toString().substring(0, 8); + if (request.getDiff() == null || request.getDiff().isBlank()) { - log.warn("Received analysis request with null or blank diff"); - return errorResponse("ERROR"); + log.warn("[{}] Received analysis request with null or blank diff", analysisId); + return recordAndReturn(OverallRisk.ERROR_UPSTREAM, analysisId); } + log.info("[{}] Starting analysis — diff length: {} chars", analysisId, request.getDiff().length()); + + String diff = prepareDiff(request.getDiff(), analysisId); + String promptPayload = buildPrompt(diff); + + // --- Call Groq (outer try: network / HTTP errors → ERROR_UPSTREAM) --- + String rawResponse; try { - String diff = request.getDiff(); + rawResponse = groqClient.callChatApi(promptPayload); + } catch (GroqApiException e) { + log.error("[{}] Groq API error: {}", analysisId, e.getMessage()); + return recordAndReturn(OverallRisk.ERROR_UPSTREAM, analysisId); + } + + log.debug("[{}] Raw LLM response length: {} chars", analysisId, rawResponse.length()); + + // --- Parse JSON (inner try: bad JSON → PARSING_ERROR) --- + try { + PrAnalysisResponse response = JsonParserUtil.toPrAnalysisResponse(rawResponse); + response.setAnalysisId(analysisId); + recordRisk(response.getOverallRisk()); + log.info("[{}] Analysis complete — risk: {}", analysisId, response.getOverallRisk()); + return response; + } catch (Exception parseEx) { + log.warn("[{}] Failed to parse LLM response: {}. Raw (first 500 chars): {}", + analysisId, + parseEx.getMessage(), + rawResponse.substring(0, Math.min(rawResponse.length(), 500))); + return recordAndReturn(OverallRisk.PARSING_ERROR, analysisId); + } + } - // Truncate oversized diffs to stay within LLM context limits - if (diff.length() > MAX_DIFF_LENGTH) { - log.info("Truncating diff from {} to {} characters", diff.length(), MAX_DIFF_LENGTH); - diff = diff.substring(0, MAX_DIFF_LENGTH); - } + private String prepareDiff(String diff, String analysisId) { + if (diff.length() <= MAX_DIFF_LENGTH) { + return diff; + } + + // Try smart prioritization first, fall back to raw truncation + String prioritized = DiffPrioritizer.prioritizeCriticalFiles(diff, MAX_DIFF_LENGTH); + log.info("[{}] Diff reduced from {} to {} chars via prioritization", + analysisId, diff.length(), prioritized.length()); + return prioritized; + } - String promptPayload = """ + private String buildPrompt(String diff) { + return """ You are a senior backend engineer reviewing a Git diff in a Java Spring Boot service. Your task is to assess RISK and propose TESTS. - + CONTEXT: - Tech stack: Java 17, Spring Boot, REST controllers, service layer, JPA repositories. - The diff may touch enums, DTOs, controllers, services, or validation logic. - + OUTPUT FORMAT (VERY IMPORTANT): 1. Output ONLY a single JSON object — no markdown, no code fences, no explanation. 2. The JSON must have EXACTLY these fields: @@ -55,45 +103,41 @@ OUTPUT FORMAT (VERY IMPORTANT): "impactAreas": [""], "suggestedTests": [""] } - 3. Be SPECIFIC: - - In impactAreas, mention concrete classes, methods, endpoints, or modules - (e.g., "OrderController#createOrder", "PaymentService", "OrderStatus enum"). - - In suggestedTests, describe targeted tests tied to those areas - (e.g., "Add unit test for OrderStatus.SHIPPED serialization in OrderController responses"). + 3. Be SPECIFIC and SPRING-BOOT AWARE: + - In impactAreas, mention concrete Spring components: + - @RestController methods (e.g., "OrderController#createOrder"). + - @Service methods (e.g., "OrderService#updateStatus"). + - JPA entities/enums (e.g., "Order entity", "OrderStatus enum"). + - Repository methods (e.g., "OrderRepository#findByStatus"). + - In suggestedTests, tie tests to those components: + - "Add unit test for OrderService#updateStatus covering NEW→SHIPPED and RETURNED flows." + - "Add @WebMvcTest for GET /orders/{id} to verify JSON serialization of all OrderStatus values." + - "Add persistence test ensuring OrderStatus enum values are stored and read correctly." 4. Semantics: - LOW: Minor or localized change, unlikely to break core flows. - MEDIUM: Affects important flows but with limited blast radius. - HIGH: Affects critical flows (payments, auth, persistence) or many modules. - 5. Always include AT LEAST 2 impactAreas and 3 suggestedTests when possible. - + 5. When the change is more than a trivial refactor, aim for: + - AT LEAST 2 impactAreas. + - AT LEAST 3 suggestedTests, mixing unit and Spring tests where relevant. + Now analyze the following Git diff and return ONLY the JSON object: - + DIFF: """ + diff; + } - String rawResponse = groqClient.callChatApi(promptPayload); - log.debug("Raw LLM response length: {} chars", rawResponse.length()); - - try { - return JsonParserUtil.toPrAnalysisResponse(rawResponse); - } catch (Exception parseEx) { - log.warn("Failed to parse LLM response: {}. Raw (first 500 chars): {}", - parseEx.getMessage(), - rawResponse.substring(0, Math.min(rawResponse.length(), 500))); - return errorResponse("PARSING_ERROR"); - } - - } catch (Exception e) { - log.error("Analysis failed: {}", e.getMessage()); - return errorResponse("PARSING_ERROR"); - } + private PrAnalysisResponse recordAndReturn(OverallRisk risk, String analysisId) { + recordRisk(risk); + return PrAnalysisResponse.error(risk, analysisId); } - private PrAnalysisResponse errorResponse(String risk) { - PrAnalysisResponse response = new PrAnalysisResponse(); - response.setOverallRisk(risk); - response.setImpactAreas(List.of()); - response.setSuggestedTests(List.of()); - return response; + private void recordRisk(OverallRisk risk) { + totalCounter.increment(); + Counter.builder("blast_radius.analyses.by_risk") + .tag("risk", risk.name()) + .description("Analyses broken down by risk level") + .register(meterRegistry) + .increment(); } } diff --git a/src/main/java/com/example/blast_radius/util/DiffPrioritizer.java b/src/main/java/com/example/blast_radius/util/DiffPrioritizer.java new file mode 100644 index 0000000..4b07e92 --- /dev/null +++ b/src/main/java/com/example/blast_radius/util/DiffPrioritizer.java @@ -0,0 +1,124 @@ +package com.example.blast_radius.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Splits a unified diff by file and keeps high-priority hunks (controllers, + * services, security, repositories) first, filling up to a byte budget. + * Falls back to raw truncation when no file headers are found. + */ +public final class DiffPrioritizer { + + private DiffPrioritizer() { + } + + // Paths that signal high-impact code — matched case-insensitively against the file path + private static final Set HIGH_PRIORITY_KEYWORDS = Set.of( + "controller", "service", "security", "repository", + "config", "auth", "filter", "interceptor", "migration" + ); + + // Matches "diff --git a/path b/path" which starts each file section in a unified diff + private static final Pattern FILE_HEADER = Pattern.compile("^diff --git a/(\\S+)", Pattern.MULTILINE); + + /** + * Prioritizes critical file hunks within a diff, up to maxLength characters. + * High-priority files (matching HIGH_PRIORITY_KEYWORDS) are included first, + * then remaining files fill whatever budget is left. + * If the diff has no parseable file headers, falls back to raw truncation. + */ + public static String prioritizeCriticalFiles(String fullDiff, int maxLength) { + List hunks = splitByFile(fullDiff); + + // No file headers found — fall back to simple truncation + if (hunks.size() <= 1) { + return fullDiff.substring(0, Math.min(fullDiff.length(), maxLength)); + } + + List highPriority = new ArrayList<>(); + List lowPriority = new ArrayList<>(); + + for (FileHunk hunk : hunks) { + if (isHighPriority(hunk.filePath)) { + highPriority.add(hunk); + } else { + lowPriority.add(hunk); + } + } + + StringBuilder result = new StringBuilder(); + int budget = maxLength; + + // Add high-priority hunks first + budget = appendHunks(result, highPriority, budget); + + // Fill remaining budget with low-priority hunks + appendHunks(result, lowPriority, budget); + + return result.toString(); + } + + private static int appendHunks(StringBuilder sb, List hunks, int budget) { + for (FileHunk hunk : hunks) { + if (budget <= 0) { + break; + } + if (hunk.content.length() <= budget) { + sb.append(hunk.content); + budget -= hunk.content.length(); + } else { + // Partial inclusion — truncate this hunk to fit the remaining budget + sb.append(hunk.content, 0, budget); + budget = 0; + } + } + return budget; + } + + private static boolean isHighPriority(String filePath) { + String lower = filePath.toLowerCase(); + return HIGH_PRIORITY_KEYWORDS.stream().anyMatch(lower::contains); + } + + static List splitByFile(String diff) { + List hunks = new ArrayList<>(); + Matcher matcher = FILE_HEADER.matcher(diff); + + List headerPositions = new ArrayList<>(); + List filePaths = new ArrayList<>(); + + while (matcher.find()) { + headerPositions.add(new int[]{matcher.start(), matcher.end()}); + filePaths.add(matcher.group(1)); + } + + if (headerPositions.isEmpty()) { + hunks.add(new FileHunk("unknown", diff)); + return hunks; + } + + for (int i = 0; i < headerPositions.size(); i++) { + int start = headerPositions.get(i)[0]; + int end = (i + 1 < headerPositions.size()) + ? headerPositions.get(i + 1)[0] + : diff.length(); + hunks.add(new FileHunk(filePaths.get(i), diff.substring(start, end))); + } + + return hunks; + } + + static class FileHunk { + final String filePath; + final String content; + + FileHunk(String filePath, String content) { + this.filePath = filePath; + this.content = content; + } + } +} diff --git a/src/main/java/com/example/blast_radius/util/JsonParserUtil.java b/src/main/java/com/example/blast_radius/util/JsonParserUtil.java index d101eb2..f7d7e45 100644 --- a/src/main/java/com/example/blast_radius/util/JsonParserUtil.java +++ b/src/main/java/com/example/blast_radius/util/JsonParserUtil.java @@ -4,6 +4,10 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +/** + * Stateless helpers for parsing LLM output into DTOs. + * Strips markdown fences, extracts the JSON object, then deserializes. + */ public final class JsonParserUtil { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() @@ -14,7 +18,8 @@ private JsonParserUtil() { /** * Strips markdown code fences and surrounding text, then parses into PrAnalysisResponse. - * Handles patterns like: ```json\n{...}\n```, ```\n{...}\n```, or raw JSON. + * Handles: ```json\n{...}\n```, ```\n{...}\n```, leading/trailing prose, or raw JSON. + * Throws on parse failure so the caller can map to PARSING_ERROR. */ public static PrAnalysisResponse toPrAnalysisResponse(String raw) throws Exception { String cleaned = stripMarkdownFences(raw); @@ -34,12 +39,10 @@ static String stripMarkdownFences(String raw) { // Strip ```json ... ``` or ``` ... ``` fences if (trimmed.startsWith("```")) { - // Remove opening fence line (e.g., "```json\n" or "```\n") int firstNewline = trimmed.indexOf('\n'); if (firstNewline != -1) { trimmed = trimmed.substring(firstNewline + 1); } - // Remove closing fence int lastFence = trimmed.lastIndexOf("```"); if (lastFence != -1) { trimmed = trimmed.substring(0, lastFence); @@ -47,7 +50,7 @@ static String stripMarkdownFences(String raw) { return trimmed.strip(); } - // No fences — try to extract the JSON object between first '{' and last '}' + // No fences — extract JSON object between first '{' and last '}' int start = trimmed.indexOf('{'); int end = trimmed.lastIndexOf('}'); if (start != -1 && end > start) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 90b675a..fdbbd3a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,3 +3,6 @@ spring.application.name=blast-radius groq.api.key=${GROQ_API_KEY} groq.api.url=https://api.groq.com/openai/v1/chat/completions groq.api.model=qwen/qwen3-32b + +# Actuator — expose health, info, metrics, and Prometheus scrape endpoint +management.endpoints.web.exposure.include=health,info,metrics,prometheus