Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main/java/io/jenkins/plugins/explain_error/AIService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.jenkins.plugins.explain_error;

import hudson.model.Run;
import java.io.IOException;
import java.util.logging.Logger;

Expand Down Expand Up @@ -43,4 +44,15 @@ private BaseAIService createServiceForProvider(GlobalConfigurationImpl config) {
public String explainError(String errorLogs) throws IOException {
return delegate.explainError(errorLogs);
}

/**
* Explain error logs using the configured AI provider with job context for logging.
* @param errorLogs the error logs to explain
* @param run the run context for logging purposes
* @return the AI explanation
* @throws IOException if there's a communication error
*/
public String explainError(String errorLogs, Run<?, ?> run) throws IOException {
return delegate.explainError(errorLogs, run);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import hudson.ProxyConfiguration;
import hudson.model.Run;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
Expand Down Expand Up @@ -33,10 +34,22 @@
* @throws IOException if there's a communication error
*/
public String explainError(String errorLogs) throws IOException {
return explainError(errorLogs, null);
}

/**
* Explain error logs using the configured AI provider with job context for logging.
* @param errorLogs the error logs to explain
* @param run the run context for logging purposes
* @return the AI explanation
* @throws IOException if there's a communication error
*/
public String explainError(String errorLogs, Run<?, ?> run) throws IOException {
if (StringUtils.isBlank(errorLogs)) {
return "No error logs provided for explanation.";
}

String jobContext = run != null ? JobContextUtil.createJobContext(run) + " " : "";
String prompt = buildPrompt(errorLogs);
String requestBody = buildRequestBody(prompt);

Expand All @@ -59,11 +72,11 @@
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String responseBody = response.body();

LOGGER.fine("Response body length: " + responseBody.length());
LOGGER.fine("Response body preview: " + responseBody.substring(0, Math.min(500, responseBody.length())));
LOGGER.fine(jobContext + "Response body length: " + responseBody.length());
LOGGER.fine(jobContext + "Response body preview: " + responseBody.substring(0, Math.min(500, responseBody.length())));

if (response.statusCode() != 200) {
LOGGER.severe("AI API request failed with status " + response.statusCode() + ": " + responseBody);
LOGGER.severe(jobContext + "AI API request failed with status " + response.statusCode() + ": " + responseBody);
return "Failed to get explanation from AI service. Status: " + response.statusCode()
+ ". Please check your API configuration and key.";
}
Expand All @@ -72,10 +85,10 @@

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOGGER.severe("AI API request was interrupted: " + e.getMessage());
LOGGER.severe(jobContext + "AI API request was interrupted: " + e.getMessage());

Check warning on line 88 in src/main/java/io/jenkins/plugins/explain_error/BaseAIService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 88 is not covered by tests
return "Request was interrupted: " + e.getMessage();
} catch (Exception e) {
LOGGER.severe("AI API request failed: " + e.getMessage());
LOGGER.severe(jobContext + "AI API request failed: " + e.getMessage());
return "Failed to communicate with AI service: " + e.getMessage();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,41 +84,43 @@
writeJsonResponse(rsp, "Error: Could not generate explanation. Please check your AI API configuration.");
}
} catch (Exception e) {
LOGGER.severe("=== EXPLAIN ERROR REQUEST FAILED ===");
LOGGER.severe("Error explaining console error: " + e.getMessage());
String jobContext = JobContextUtil.createJobContext(run);
LOGGER.severe(jobContext + " === EXPLAIN ERROR REQUEST FAILED ===");
LOGGER.severe(jobContext + " Error explaining console error: " + e.getMessage());
writeJsonResponse(rsp, "Error: " + e.getMessage());
}
}

/**
* AJAX endpoint to check if an explanation already exists.
* Returns JSON with hasExplanation boolean and timestamp if it exists.
*/
@RequirePOST
public void doCheckExistingExplanation(StaplerRequest2 req, StaplerResponse2 rsp) throws ServletException, IOException {
try {
run.checkPermission(hudson.model.Item.READ);

ErrorExplanationAction existingAction = run.getAction(ErrorExplanationAction.class);
boolean hasExplanation = existingAction != null && existingAction.hasValidExplanation();

rsp.setContentType("application/json");
rsp.setCharacterEncoding("UTF-8");
PrintWriter writer = rsp.getWriter();

if (hasExplanation) {
String response = String.format(
"{\"hasExplanation\": true, \"timestamp\": \"%s\"}",
existingAction.getFormattedTimestamp()
);
writer.write(response);
} else {
writer.write("{\"hasExplanation\": false}");
}

writer.flush();
} catch (Exception e) {
LOGGER.severe("Error checking existing explanation: " + e.getMessage());
String jobContext = JobContextUtil.createJobContext(run);
LOGGER.severe(jobContext + " Error checking existing explanation: " + e.getMessage());

Check warning on line 123 in src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorAction.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 87-123 are not covered by tests
rsp.setStatus(500);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
ConsoleExplainErrorAction action = new ConsoleExplainErrorAction(run);
return Collections.singletonList(action);
} catch (Exception e) {
LOGGER.severe("Failed to create ConsoleExplainErrorAction for run: " + run.getFullDisplayName() + ". Error: " + e.getMessage());
String jobContext = JobContextUtil.createJobContext(run);
LOGGER.severe(jobContext + " Failed to create ConsoleExplainErrorAction. Error: " + e.getMessage());

Check warning on line 37 in src/main/java/io/jenkins/plugins/explain_error/ConsoleExplainErrorActionFactory.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 36-37 are not covered by tests
return Collections.emptyList();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import hudson.Extension;
import hudson.model.PageDecorator;
import hudson.model.Run;
import hudson.util.Secret;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest2;

/**
* Page decorator to add "Explain Error" functionality to console output pages.
Expand Down Expand Up @@ -33,4 +36,24 @@
// If API URL is set to a non-empty value, that's also valid
return true;
}

/**
* Get job context for the current request if it's a console page.
* @return job context string like "[JobName #BuildNumber]" or empty string if not applicable
*/
public String getJobContext() {
StaplerRequest2 request = Stapler.getCurrentRequest2();
if (request == null) {
return "";
}

// Get the current object from the request
Object ancestor = request.findAncestorObject(Run.class);
if (ancestor instanceof Run) {
Run<?, ?> run = (Run<?, ?>) ancestor;
return JobContextUtil.createJobContext(run);
}

return "";

Check warning on line 57 in src/main/java/io/jenkins/plugins/explain_error/ConsolePageDecorator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 45-57 are not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName());

public void explainError(Run<?, ?> run, TaskListener listener, String logPattern, int maxLines) {
String jobContext = JobContextUtil.createJobContext(run);

try {
GlobalConfigurationImpl config = GlobalConfigurationImpl.get();

Expand All @@ -38,18 +40,18 @@
return;
}

// Get AI explanation
// Get AI explanation with job context
AIService aiService = new AIService(config);
String explanation = aiService.explainError(errorLogs);
String explanation = aiService.explainError(errorLogs, run);

// Store explanation in build action
ErrorExplanationAction action = new ErrorExplanationAction(explanation, errorLogs);
run.addOrReplaceAction(action);

// Explanation is now available on the job page, no need to clutter console output

} catch (Exception e) {
LOGGER.severe("Failed to explain error: " + e.getMessage());
LOGGER.severe(jobContext + " Failed to explain error: " + e.getMessage());

Check warning on line 54 in src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 45-54 are not covered by tests
listener.getLogger().println("Failed to explain error: " + e.getMessage());
}
}
Expand Down Expand Up @@ -79,37 +81,38 @@
* Used for console output error explanation.
*/
public String explainErrorText(String errorText, Run<?, ?> run) {
String jobContext = JobContextUtil.createJobContext(run);

try {
GlobalConfigurationImpl config = GlobalConfigurationImpl.get();

if (!config.isEnableExplanation()) {
LOGGER.warning("AI error explanation is disabled in global configuration");
return "AI error explanation is disabled in global configuration.";
LOGGER.warning(jobContext + " AI error explanation is disabled in global configuration");
return jobContext + " AI error explanation is disabled in global configuration.";
}

if (config.getApiKey() == null || StringUtils.isBlank(config.getApiKey().getPlainText())) {
LOGGER.warning("API key is not configured");
LOGGER.warning(jobContext + " API key is not configured");

Check warning on line 95 in src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 90-95 are not covered by tests
return "ERROR: API key is not configured. Please configure it in Jenkins global settings.";
}

if (StringUtils.isBlank(errorText)) {
LOGGER.warning("No error text provided");
LOGGER.warning(jobContext + " No error text provided");
return "No error text provided to explain.";
}

// Get AI explanation
// Get AI explanation with job context
AIService aiService = new AIService(config);
String explanation = aiService.explainError(errorText);
String explanation = aiService.explainError(errorText, run);

LOGGER.fine("Explanation length: " + (explanation != null ? explanation.length() : 0));
LOGGER.fine(jobContext + " Explanation length: " + (explanation != null ? explanation.length() : 0));

Check warning on line 108 in src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 108 is only partially covered, one branch is missing

return explanation;

} catch (Exception e) {
LOGGER.severe("Failed to explain error text: " + e.getMessage());
LOGGER.severe(jobContext + " Failed to explain error text: " + e.getMessage());
e.printStackTrace();
return "Failed to explain error: " + e.getMessage();
return jobContext + " Failed to explain error: " + e.getMessage();

Check warning on line 115 in src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 113-115 are not covered by tests
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ public String getDisplayName() {
return "AI Error Explanation";
}

/**
* Get job context for display in card titles.
* @return job context string like "[JobName #BuildNumber]" or empty string if no run
*/
public String getJobContext() {
if (run != null) {
return JobContextUtil.createJobContext(run);
}
return "";
}

@Override
public String getUrlName() {
return "error-explanation";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.jenkins.plugins.explain_error;

import hudson.model.Run;

/**
* Utility class for creating job context information for logging.
* This helps identify which specific job encountered an error when multiple jobs
* are running concurrently and using the AI service.
*/
public class JobContextUtil {

Check warning on line 10 in src/main/java/io/jenkins/plugins/explain_error/JobContextUtil.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 10 is not covered by tests

/**
* Create a standardized job context string for logging.
* Format: [JobName #BuildNumber]
*
* @param run the build run
* @return formatted job context string
*/
public static String createJobContext(Run<?, ?> run) {
if (run == null) {
return "[Unknown Job]";
}

String jobName = run.getParent().getFullName();
int buildNumber = run.getNumber();

return String.format("[%s #%d]", jobName, buildNumber);
}

/**
* Create a job context string with additional details for logging.
* Format: [JobName #BuildNumber - DisplayName]
*
* @param run the build run
* @return formatted detailed job context string
*/
public static String createDetailedJobContext(Run<?, ?> run) {
if (run == null) {
return "[Unknown Job]";
}

String jobName = run.getParent().getFullName();
int buildNumber = run.getNumber();
String displayName = run.getDisplayName();

return String.format("[%s #%d - %s]", jobName, buildNumber, displayName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<j:if test="${it.explainErrorEnabled}">
<script src="${rootURL}/plugin/explain-error/js/explain-error-footer.js" type="text/javascript"/>
<div id="explain-error-container" class="jenkins-hidden">
<l:card title="AI Error Explanation">
<l:card title="${it.jobContext} AI Error Explanation">
<div id="explain-error-spinner" class="jenkins-hidden">
<l:spinner text="Analyzing error logs..."/>
</div>
Expand All @@ -13,7 +13,7 @@

<!-- Confirmation Dialog for existing explanation -->
<div id="explain-error-confirm-dialog" class="jenkins-hidden">
<l:card title="AI Error Explanation Exists">
<l:card title="${it.jobContext} AI Error Explanation Exists">
<p>An AI explanation was already generated on <span id="existing-explanation-timestamp"></span>.</p>
<p>Do you want to view the existing explanation or generate a new one?</p>
<div class="jenkins-button-bar">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<l:main-panel>
<h1>AI Error Explanation</h1>

<l:card title="Generated on: ${it.formattedTimestamp}">
<l:card title="${it.jobContext} - Generated on: ${it.formattedTimestamp}">
<pre style="white-space: pre-wrap; word-wrap: break-word;" class="jenkins-!-margin-bottom-0">${it.explanation}</pre>
</l:card>
</l:main-panel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,34 @@ void testGetIconFileName() {

@Test
void testGetDisplayName() {
// When no run is attached, should return default name
assertEquals("AI Error Explanation", action.getDisplayName());
}

@Test
@WithJenkins
void testGetJobContext(JenkinsRule jenkins) throws Exception {
// Create a mock run
FreeStyleProject project = jenkins.createFreeStyleProject("test-job");
FreeStyleBuild build = jenkins.buildAndAssertSuccess(project);

// Attach the run to the action
action.onAttached(build);

// Should return job context
String jobContext = action.getJobContext();
assertEquals("[test-job #1]", jobContext);

// Display name should still be the default
assertEquals("AI Error Explanation", action.getDisplayName());
}

@Test
void testGetJobContextWithoutRun() {
// When no run is attached, should return empty string
assertEquals("", action.getJobContext());
}

@Test
void testGetUrlName() {
assertEquals("error-explanation", action.getUrlName());
Expand Down
Loading
Loading