From d95b371741dab74c9abf2cd989e6937e28d77803 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 21 Mar 2026 20:43:04 +0200 Subject: [PATCH 01/14] feat: implement AI auto-fix PR creation (autofix module) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AutoFixOrchestrator: coordinates AI suggestion → diff validation → SCM branch creation → atomic commit → PR creation - Add FixSuggestion / FileChange data models with Jackson deserialization - Add FixAssistant LangChain4j interface for structured JSON AI output - Add UnifiedDiffApplier with fuzzy ±3 line matching and empty-file support - Add SCM layer: ScmApiClient interface + GitHubApiClient (Trees API), GitLabApiClient (Commits API), BitbucketApiClient (Cloud API v2) - Add AutoFixAction (RunAction2) with Jenkins UI Jelly template - Add AutoFixStatus / AutoFixResult enums and result value objects - Extend ExplainErrorStep with 9 new autoFix* pipeline parameters - Extend ErrorExplainer with getLastErrorLogs() and getResolvedProvider() - Update all 4 AI providers to expose createFixAssistant() - Add plain-credentials + git plugin (optional) deps to pom.xml - Add WireMock integration tests (GitHubApiClientTest, 13 cases) - Add UnifiedDiffApplier unit tests (16 cases) Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 21 + .../plugins/explain_error/ErrorExplainer.java | 22 + .../explain_error/ExplainErrorStep.java | 126 +++++ .../explain_error/autofix/AutoFixAction.java | 151 +++++ .../autofix/AutoFixOrchestrator.java | 521 ++++++++++++++++++ .../explain_error/autofix/AutoFixResult.java | 75 +++ .../explain_error/autofix/AutoFixStatus.java | 10 + .../explain_error/autofix/FixAssistant.java | 38 ++ .../explain_error/autofix/FixSuggestion.java | 20 + .../autofix/UnifiedDiffApplier.java | 222 ++++++++ .../autofix/scm/BitbucketApiClient.java | 270 +++++++++ .../autofix/scm/GitHubApiClient.java | 272 +++++++++ .../autofix/scm/GitLabApiClient.java | 266 +++++++++ .../autofix/scm/PullRequest.java | 7 + .../autofix/scm/ScmApiClient.java | 73 +++ .../autofix/scm/ScmClientFactory.java | 14 + .../explain_error/autofix/scm/ScmRepo.java | 78 +++ .../explain_error/autofix/scm/ScmType.java | 7 + .../provider/BaseAIProvider.java | 2 + .../provider/BedrockProvider.java | 14 +- .../provider/GeminiProvider.java | 15 +- .../provider/OllamaProvider.java | 14 +- .../provider/OpenAIProvider.java | 15 +- .../ExplainErrorStep/config.jelly | 55 +- .../autofix/AutoFixAction/index.jelly | 22 + .../autofix/AutoFixAction/index.properties | 1 + .../autofix/GitHubApiClientTest.java | 219 ++++++++ .../autofix/UnifiedDiffApplierTest.java | 159 ++++++ 28 files changed, 2695 insertions(+), 14 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixAction.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixResult.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixStatus.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/FixAssistant.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/FixSuggestion.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/BitbucketApiClient.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitHubApiClient.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitLabApiClient.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/PullRequest.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmApiClient.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmClientFactory.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java create mode 100644 src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmType.java create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.jelly create mode 100644 src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.properties create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/GitHubApiClientTest.java create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplierTest.java diff --git a/pom.xml b/pom.xml index 49500087..34644b41 100644 --- a/pom.xml +++ b/pom.xml @@ -129,6 +129,19 @@ cloudbees-folder + + + org.jenkins-ci.plugins + plain-credentials + + + + + org.jenkins-ci.plugins + git + true + + org.jenkins-ci.plugins.workflow @@ -160,6 +173,14 @@ test + + + com.github.tomakehurst + wiremock-standalone + 3.0.1 + test + + dev.langchain4j langchain4j diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 0f1a3285..d597942d 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -26,6 +26,7 @@ public class ErrorExplainer { private String providerName; private String urlString; + private String lastErrorLogs; private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName()); @@ -33,6 +34,26 @@ public String getProviderName() { return providerName; } + /** + * Returns the error logs extracted during the last call to {@link #explainError}. + * Returns {@code null} if {@code explainError} has not been called yet. + */ + public String getLastErrorLogs() { + return lastErrorLogs; + } + + /** + * Returns the resolved AI provider for the given run (folder-level first, then global). + * Delegates to the private {@link #resolveProvider(Run)} method. + * + * @param run the build run to resolve the provider for + * @return the resolved AI provider, or {@code null} if none is configured + */ + @CheckForNull + public BaseAIProvider getResolvedProvider(@CheckForNull Run run) { + return resolveProvider(run); + } + public String explainError(Run run, TaskListener listener, String logPattern, int maxLines) { return explainError(run, listener, logPattern, maxLines, null, null, false, null, null); } @@ -72,6 +93,7 @@ String explainError(Run run, TaskListener listener, String logPattern, int // Extract error logs String errorLogs = extractErrorLogs(run, logPattern, maxLines, collectDownstreamLogs, downstreamJobPattern, authentication); + this.lastErrorLogs = errorLogs; // Use step-level customContext if provided, otherwise fallback to global String effectiveCustomContext = StringUtils.isNotBlank(customContext) ? customContext : GlobalConfigurationImpl.get().getCustomContext(); diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java index 6baaabaf..ef7935a7 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -3,7 +3,13 @@ import hudson.Extension; import hudson.model.Run; import hudson.model.TaskListener; +import io.jenkins.plugins.explain_error.autofix.AutoFixOrchestrator; +import io.jenkins.plugins.explain_error.autofix.AutoFixResult; +import io.jenkins.plugins.explain_error.autofix.AutoFixStatus; +import java.util.Arrays; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import jenkins.model.Jenkins; import org.jenkinsci.plugins.workflow.steps.Step; import org.jenkinsci.plugins.workflow.steps.StepContext; @@ -25,6 +31,17 @@ public class ExplainErrorStep extends Step { private boolean collectDownstreamLogs; private String downstreamJobPattern; + // Auto-fix fields + private boolean autoFix = false; + private String autoFixCredentialsId = ""; + private String autoFixScmType = ""; + private String autoFixGithubEnterpriseUrl = ""; + private String autoFixGitlabUrl = ""; + private String autoFixBitbucketUrl = ""; + private String autoFixAllowedPaths = "pom.xml,build.gradle,build.gradle.kts,*.properties,*.yml,*.yaml,Jenkinsfile,Dockerfile,package.json,requirements.txt,go.mod"; + private boolean autoFixDraftPr = false; + private int autoFixTimeoutSeconds = 60; + @DataBoundConstructor public ExplainErrorStep() { this.logPattern = ""; @@ -89,6 +106,88 @@ public void setDownstreamJobPattern(String downstreamJobPattern) { this.downstreamJobPattern = downstreamJobPattern != null ? downstreamJobPattern : ""; } + public boolean isAutoFix() { + return autoFix; + } + + @DataBoundSetter + public void setAutoFix(boolean autoFix) { + this.autoFix = autoFix; + } + + public String getAutoFixCredentialsId() { + return autoFixCredentialsId; + } + + @DataBoundSetter + public void setAutoFixCredentialsId(String autoFixCredentialsId) { + this.autoFixCredentialsId = autoFixCredentialsId != null ? autoFixCredentialsId : ""; + } + + public String getAutoFixScmType() { + return autoFixScmType; + } + + @DataBoundSetter + public void setAutoFixScmType(String autoFixScmType) { + this.autoFixScmType = autoFixScmType != null ? autoFixScmType : ""; + } + + public String getAutoFixGithubEnterpriseUrl() { + return autoFixGithubEnterpriseUrl; + } + + @DataBoundSetter + public void setAutoFixGithubEnterpriseUrl(String autoFixGithubEnterpriseUrl) { + this.autoFixGithubEnterpriseUrl = autoFixGithubEnterpriseUrl != null ? autoFixGithubEnterpriseUrl : ""; + } + + public String getAutoFixGitlabUrl() { + return autoFixGitlabUrl; + } + + @DataBoundSetter + public void setAutoFixGitlabUrl(String autoFixGitlabUrl) { + this.autoFixGitlabUrl = autoFixGitlabUrl != null ? autoFixGitlabUrl : ""; + } + + public String getAutoFixBitbucketUrl() { + return autoFixBitbucketUrl; + } + + @DataBoundSetter + public void setAutoFixBitbucketUrl(String autoFixBitbucketUrl) { + this.autoFixBitbucketUrl = autoFixBitbucketUrl != null ? autoFixBitbucketUrl : ""; + } + + public String getAutoFixAllowedPaths() { + return autoFixAllowedPaths; + } + + @DataBoundSetter + public void setAutoFixAllowedPaths(String autoFixAllowedPaths) { + this.autoFixAllowedPaths = autoFixAllowedPaths != null ? autoFixAllowedPaths + : "pom.xml,build.gradle,build.gradle.kts,*.properties,*.yml,*.yaml,Jenkinsfile,Dockerfile,package.json,requirements.txt,go.mod"; + } + + public boolean isAutoFixDraftPr() { + return autoFixDraftPr; + } + + @DataBoundSetter + public void setAutoFixDraftPr(boolean autoFixDraftPr) { + this.autoFixDraftPr = autoFixDraftPr; + } + + public int getAutoFixTimeoutSeconds() { + return autoFixTimeoutSeconds; + } + + @DataBoundSetter + public void setAutoFixTimeoutSeconds(int autoFixTimeoutSeconds) { + this.autoFixTimeoutSeconds = autoFixTimeoutSeconds > 0 ? autoFixTimeoutSeconds : 60; + } + @Override public StepExecution start(StepContext context) throws Exception { return new ExplainErrorStepExecution(context, this); @@ -133,6 +232,33 @@ protected String run() throws Exception { step.getLanguage(), step.getCustomContext(), step.isCollectDownstreamLogs(), step.getDownstreamJobPattern(), Jenkins.getAuthentication2()); + if (step.isAutoFix()) { + String errorLogs = explainer.getLastErrorLogs(); + AutoFixOrchestrator orchestrator = new AutoFixOrchestrator(); + List allowedPaths = Arrays.stream(step.getAutoFixAllowedPaths().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + AutoFixResult fixResult = orchestrator.attemptAutoFix( + run, + errorLogs, + explainer.getResolvedProvider(run), + step.getAutoFixCredentialsId(), + step.getAutoFixScmType().isEmpty() ? null : step.getAutoFixScmType(), + step.getAutoFixGithubEnterpriseUrl().isEmpty() ? null : step.getAutoFixGithubEnterpriseUrl(), + step.getAutoFixGitlabUrl().isEmpty() ? null : step.getAutoFixGitlabUrl(), + step.getAutoFixBitbucketUrl().isEmpty() ? null : step.getAutoFixBitbucketUrl(), + allowedPaths, + step.isAutoFixDraftPr(), + step.getAutoFixTimeoutSeconds(), + listener); + listener.getLogger().println("[AutoFix] Status: " + fixResult.getStatus() + " - " + fixResult.getMessage()); + if (fixResult.getStatus() == AutoFixStatus.CREATED) { + listener.getLogger().println("[AutoFix] PR created: " + fixResult.getPrUrl()); + } + } + return explanation; } } diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixAction.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixAction.java new file mode 100644 index 00000000..3c31c440 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixAction.java @@ -0,0 +1,151 @@ +package io.jenkins.plugins.explain_error.autofix; + +import hudson.model.Run; +import jenkins.model.RunAction2; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * A Jenkins {@link RunAction2} that stores auto-fix results and provides + * a UI page under the build's action sidebar. + */ +public class AutoFixAction implements RunAction2 { + + private final AutoFixStatus status; + private final String prUrl; // nullable + private final String branchName; // nullable + private final String message; + private final String prTitle; // nullable + private final long timestamp; + private final String scmType; // "GitHub", "GitLab", "Bitbucket", nullable + + private transient Run run; + + /** + * Creates an AutoFixAction with all fields. + * + * @param status the result status + * @param prUrl the URL of the created PR, or null + * @param branchName the fix branch name, or null + * @param message human-readable status message + * @param prTitle the PR title, or null + * @param timestamp epoch millis when this action was created + * @param scmType "GITHUB", "GITLAB", "BITBUCKET", or null + */ + public AutoFixAction(AutoFixStatus status, String prUrl, String branchName, + String message, String prTitle, long timestamp, String scmType) { + this.status = status; + this.prUrl = prUrl; + this.branchName = branchName; + this.message = message; + this.prTitle = prTitle; + this.timestamp = timestamp; + this.scmType = scmType; + } + + @Override + public String getIconFileName() { + return "symbol-git-pull-request-outline plugin-ionicons-api"; + } + + @Override + public String getDisplayName() { + return "AI Auto-Fix"; + } + + @Override + public String getUrlName() { + return "auto-fix"; + } + + @Override + public void onAttached(Run r) { + this.run = r; + } + + @Override + public void onLoad(Run r) { + this.run = r; + } + + /** + * Used for XStream deserialization backward compatibility. + * All fields are final and serialized; no migration needed. + */ + protected Object readResolve() { + return this; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + public AutoFixStatus getStatus() { + return status; + } + + public String getPrUrl() { + return prUrl; + } + + public String getBranchName() { + return branchName; + } + + public String getMessage() { + return message; + } + + public String getPrTitle() { + return prTitle; + } + + public long getTimestamp() { + return timestamp; + } + + public String getScmType() { + return scmType; + } + + public Run getRun() { + return run; + } + + // ------------------------------------------------------------------------- + // Convenience methods + // ------------------------------------------------------------------------- + + /** + * Returns true when this action represents a successfully created PR. + */ + public boolean hasCreatedPr() { + return status == AutoFixStatus.CREATED; + } + + /** + * Returns a human-readable label for the current status. + */ + public String getStatusDisplayName() { + if (status == null) { + return "Unknown"; + } + return switch (status) { + case CREATED -> "PR Created"; + case FAILED -> "Failed"; + case NOT_APPLICABLE -> "Not Applicable"; + case SKIPPED_LOW_CONFIDENCE -> "Skipped (Low Confidence)"; + case SKIPPED_PATH_NOT_ALLOWED -> "Skipped (Path Not Allowed)"; + case TIMED_OUT -> "Timed Out"; + }; + } + + /** + * Returns a formatted date/time string for the action timestamp. + */ + public String getFormattedTimestamp() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); + return sdf.format(new Date(timestamp)); + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java new file mode 100644 index 00000000..38b60846 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -0,0 +1,521 @@ +package io.jenkins.plugins.explain_error.autofix; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import hudson.model.Run; +import hudson.model.TaskListener; +import io.jenkins.plugins.explain_error.autofix.scm.ScmApiClient; +import io.jenkins.plugins.explain_error.autofix.scm.ScmClientFactory; +import io.jenkins.plugins.explain_error.autofix.scm.ScmRepo; +import io.jenkins.plugins.explain_error.autofix.scm.ScmType; +import io.jenkins.plugins.explain_error.autofix.scm.PullRequest; +import io.jenkins.plugins.explain_error.provider.BaseAIProvider; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Main coordinator for the AI auto-fix workflow. + * + *

Asks the configured AI provider for a fix suggestion, validates all file paths + * and diffs, applies changes to a new branch via the SCM API, and opens a pull request. + */ +public class AutoFixOrchestrator { + + private static final Logger LOGGER = Logger.getLogger(AutoFixOrchestrator.class.getName()); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** Default PR body template with {@code {variable}} placeholders. */ + private static final String DEFAULT_PR_TEMPLATE = """ + ## AI Auto-Fix for {jobName} #{buildNumber} + + This pull request was automatically generated by the [Explain Error Plugin](https://plugins.jenkins.io/explain-error) \ + to address a build failure. + + ### Root Cause + + {explanation} + + ### Changes + + {changesSummary} + + ### Build Details + + - **Job:** {jobName} + - **Build:** #{buildNumber} + - **Fix Type:** {fixType} + - **Confidence:** {confidence} + + --- + *Generated by AI Auto-Fix — please review all changes carefully before merging.* + """; + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Attempts to automatically fix the build failure described by {@code errorLogs}. + * + * @param run the failing build run + * @param errorLogs the error logs to analyse + * @param aiProvider the configured AI provider + * @param credentialsId Jenkins credentials ID for SCM token (StringCredentials) + * @param scmTypeOverride optional override for SCM type detection ("github"/"gitlab"/"bitbucket") + * @param githubEnterpriseUrl optional GitHub Enterprise API base URL (e.g. https://ghe.example.com) + * @param gitlabUrl optional self-hosted GitLab base URL + * @param bitbucketUrl optional self-hosted Bitbucket base URL + * @param allowedPathGlobs list of glob patterns that restrict which files may be changed + * @param draftPr whether to open a draft PR + * @param timeoutSeconds overall timeout in seconds + * @param listener task listener for build-log output + * @return the result of the auto-fix attempt + */ + public AutoFixResult attemptAutoFix( + Run run, + String errorLogs, + BaseAIProvider aiProvider, + String credentialsId, + String scmTypeOverride, + String githubEnterpriseUrl, + String gitlabUrl, + String bitbucketUrl, + List allowedPathGlobs, + boolean draftPr, + int timeoutSeconds, + TaskListener listener) { + + // We keep track of the branch name so we can clean up on timeout/failure. + final String[] createdBranchRef = {null}; + + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + return doAttemptAutoFix( + run, errorLogs, aiProvider, credentialsId, + scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl, + allowedPathGlobs, draftPr, listener, createdBranchRef); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Auto-fix failed with exception", e); + listener.getLogger().println("[AutoFix] Error: " + e.getMessage()); + return AutoFixResult.failed("Auto-fix encountered an unexpected error: " + e.getMessage()); + } + }); + + try { + return future.get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + listener.getLogger().println("[AutoFix] Timed out after " + timeoutSeconds + " seconds."); + // Best-effort branch cleanup + if (createdBranchRef[0] != null) { + try { + String remoteUrl = extractRemoteUrl(run); + StringCredentials creds = CredentialsProvider.findCredentialById( + credentialsId, StringCredentials.class, run, Collections.emptyList()); + if (creds != null) { + ScmRepo repo = buildScmRepo(remoteUrl, creds.getSecret().getPlainText(), + scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl); + ScmApiClient client = ScmClientFactory.create(repo); + client.deleteBranch(createdBranchRef[0]); + } + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Failed to clean up branch after timeout", ex); + } + } + return AutoFixResult.timedOut(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return AutoFixResult.failed("Auto-fix interrupted."); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + LOGGER.log(Level.WARNING, "Auto-fix execution failed", cause); + return AutoFixResult.failed("Auto-fix execution failed: " + (cause != null ? cause.getMessage() : e.getMessage())); + } + } + + // ------------------------------------------------------------------------- + // Core logic + // ------------------------------------------------------------------------- + + private AutoFixResult doAttemptAutoFix( + Run run, + String errorLogs, + BaseAIProvider aiProvider, + String credentialsId, + String scmTypeOverride, + String githubEnterpriseUrl, + String gitlabUrl, + String bitbucketUrl, + List allowedPathGlobs, + boolean draftPr, + TaskListener listener, + String[] createdBranchRef) throws Exception { + + listener.getLogger().println("[AutoFix] Requesting fix suggestion from AI provider..."); + + // Step 1 — Get AI fix suggestion + FixAssistant fixAssistant = aiProvider.createFixAssistant(); + String rawJson = fixAssistant.suggestFix(errorLogs); + LOGGER.fine("Raw AI response: " + rawJson); + FixSuggestion suggestion = parseFixSuggestion(rawJson); + + listener.getLogger().println("[AutoFix] AI suggestion: fixable=" + suggestion.fixable() + + ", confidence=" + suggestion.confidence() + + ", fixType=" + suggestion.fixType()); + + // Step 2 — Check fixability + if (!suggestion.fixable() || "low".equals(suggestion.confidence())) { + listener.getLogger().println("[AutoFix] Skipping: AI confidence too low or not fixable."); + return AutoFixResult.skippedLowConfidence(); + } + + // Step 3 — Validate file paths + if (suggestion.changes() != null) { + for (FixSuggestion.FileChange change : suggestion.changes()) { + String filePath = change.filePath(); + String pathError = validateFilePath(filePath, allowedPathGlobs); + if (pathError != null) { + listener.getLogger().println("[AutoFix] Skipping: " + pathError); + return AutoFixResult.skippedPathNotAllowed(filePath); + } + } + } + + // Step 4 — Validate all diffs + if (suggestion.changes() != null) { + for (FixSuggestion.FileChange change : suggestion.changes()) { + if ("modify".equals(change.action())) { + String diffError = UnifiedDiffApplier.validate(change.unifiedDiff()); + if (diffError != null) { + String msg = "Invalid diff for " + change.filePath() + ": " + diffError; + listener.getLogger().println("[AutoFix] Failed: " + msg); + return AutoFixResult.failed(msg); + } + } + } + } + + // Step 5 — Detect SCM remote URL and credentials + String remoteUrl = extractRemoteUrl(run); + listener.getLogger().println("[AutoFix] Detected SCM remote: " + remoteUrl); + + StringCredentials creds = CredentialsProvider.findCredentialById( + credentialsId, StringCredentials.class, run, Collections.emptyList()); + if (creds == null) { + return AutoFixResult.failed("SCM credentials not found for ID: " + credentialsId); + } + String token = creds.getSecret().getPlainText(); + + // Step 6 — Parse SCM repo (with enterprise overrides) + ScmRepo repo = buildScmRepo(remoteUrl, token, scmTypeOverride, + githubEnterpriseUrl, gitlabUrl, bitbucketUrl); + listener.getLogger().println("[AutoFix] SCM type: " + repo.scmType() + + ", owner: " + repo.owner() + ", repo: " + repo.repoName()); + + ScmApiClient client = ScmClientFactory.create(repo); + + // Step 7 — Validate credentials / write access + listener.getLogger().println("[AutoFix] Validating write access..."); + client.validateWriteAccess(); + + // Determine default branch + String defaultBranch = client.getDefaultBranch(); + listener.getLogger().println("[AutoFix] Default branch: " + defaultBranch); + + // Step 8 — Create fix branch + String branchName = "fix/jenkins-ai-" + run.getNumber() + "-" + System.currentTimeMillis(); + listener.getLogger().println("[AutoFix] Creating branch: " + branchName); + client.createBranch(branchName, defaultBranch); + createdBranchRef[0] = branchName; + + // Step 9 — Apply diffs, collect new file contents + Map fileContents = new LinkedHashMap<>(); + if (suggestion.changes() != null) { + for (FixSuggestion.FileChange change : suggestion.changes()) { + String filePath = change.filePath(); + listener.getLogger().println("[AutoFix] Preparing change for: " + filePath + " (" + change.action() + ")"); + + String currentContent = client.getFileContent(filePath, defaultBranch); + + String newContent; + if ("create".equals(change.action())) { + newContent = extractNewContent(change.unifiedDiff()); + } else { + // modify + if (currentContent == null) { + String msg = "Cannot modify non-existent file: " + filePath; + listener.getLogger().println("[AutoFix] Failed: " + msg); + rollbackBranch(client, branchName, listener); + return AutoFixResult.failed(msg); + } + try { + newContent = UnifiedDiffApplier.apply(currentContent, change.unifiedDiff()); + } catch (IllegalArgumentException e) { + String msg = "Failed to apply diff to " + filePath + ": " + e.getMessage(); + listener.getLogger().println("[AutoFix] Failed: " + msg); + rollbackBranch(client, branchName, listener); + return AutoFixResult.failed(msg); + } + } + fileContents.put(filePath, newContent); + } + } + + // Commit all changes atomically + String commitMessage = "fix: AI auto-fix for build #" + run.getNumber(); + try { + client.commitFiles(branchName, commitMessage, fileContents); + } catch (IOException e) { + String msg = "Failed to commit files: " + e.getMessage(); + listener.getLogger().println("[AutoFix] Failed: " + msg); + rollbackBranch(client, branchName, listener); + return AutoFixResult.failed(msg); + } + + // Step 10 — Create PR + String prTitle = "fix: AI auto-fix for " + run.getParent().getFullName() + " #" + run.getNumber(); + String prBody = buildPrBody(run, suggestion, DEFAULT_PR_TEMPLATE); + + PullRequest pr; + try { + pr = client.createPullRequest(prTitle, prBody, branchName, defaultBranch, draftPr); + } catch (IOException e) { + String msg = "Failed to create pull request: " + e.getMessage(); + listener.getLogger().println("[AutoFix] Failed: " + msg); + rollbackBranch(client, branchName, listener); + return AutoFixResult.failed(msg); + } + + listener.getLogger().println("[AutoFix] Pull request created: " + pr.url()); + + // Step 11 — Store action and return + AutoFixAction action = new AutoFixAction( + AutoFixStatus.CREATED, + pr.url(), + branchName, + "PR created: " + pr.url(), + prTitle, + System.currentTimeMillis(), + repo.scmType().name()); + run.addOrReplaceAction(action); + try { + run.save(); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to persist AutoFixAction to build", e); + } + + return AutoFixResult.created(pr.url(), branchName); + } + + // ------------------------------------------------------------------------- + // Helper methods + // ------------------------------------------------------------------------- + + /** + * Parses a raw string from the AI (which may contain preamble) into a {@link FixSuggestion}. + * Extracts the JSON block by finding the first '{' and last '}'. + */ + FixSuggestion parseFixSuggestion(String rawJson) throws IOException { + if (rawJson == null || rawJson.isBlank()) { + throw new IOException("AI returned empty response"); + } + int start = rawJson.indexOf('{'); + int end = rawJson.lastIndexOf('}'); + if (start < 0 || end < 0 || start >= end) { + throw new IOException("No JSON object found in AI response: " + rawJson); + } + String jsonBlock = rawJson.substring(start, end + 1); + return OBJECT_MAPPER.readValue(jsonBlock, FixSuggestion.class); + } + + /** + * Validates a file path against security constraints and the allowed-path globs. + * + * @return null if path is valid, or a description of why it was rejected + */ + private String validateFilePath(String filePath, List allowedPathGlobs) { + if (filePath == null || filePath.isBlank()) { + return "File path is null or blank"; + } + if (filePath.startsWith("/")) { + return "Absolute paths are not allowed: " + filePath; + } + if (filePath.contains("../") || filePath.contains("..\\")) { + return "Path traversal is not allowed: " + filePath; + } + if (allowedPathGlobs != null && !allowedPathGlobs.isEmpty()) { + boolean matched = false; + Path p = Path.of(filePath); + for (String glob : allowedPathGlobs) { + try { + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + if (matcher.matches(p) || matcher.matches(Path.of(p.getFileName().toString()))) { + matched = true; + break; + } + } catch (Exception e) { + LOGGER.warning("Invalid glob pattern '" + glob + "': " + e.getMessage()); + } + } + if (!matched) { + return "File path '" + filePath + "' is not allowed by configured path patterns"; + } + } + return null; + } + + /** + * Extracts the remote URL from the run's SCM configuration via reflection (to avoid + * a hard compile-time dependency on the git plugin). + */ + String extractRemoteUrl(Run run) { + if (!(run.getParent() instanceof hudson.model.AbstractProject project)) { + throw new IllegalStateException("Job type " + run.getParent().getClass().getName() + " does not support SCM URL extraction"); + } + hudson.scm.SCM scm = project.getScm(); + if (scm == null) { + throw new IllegalStateException("No SCM configured on this job"); + } + + String scmClassName = scm.getClass().getName(); + + // Support hudson.plugins.git.GitSCM via reflection + if (scmClassName.equals("hudson.plugins.git.GitSCM")) { + try { + java.lang.reflect.Method getRepositories = scm.getClass().getMethod("getRepositories"); + @SuppressWarnings("unchecked") + java.util.List repos = (java.util.List) getRepositories.invoke(scm); + if (!repos.isEmpty()) { + Object repo = repos.get(0); + java.lang.reflect.Method getUrls = repo.getClass().getMethod("getURIs"); + @SuppressWarnings("unchecked") + java.util.List uris = (java.util.List) getUrls.invoke(repo); + if (!uris.isEmpty()) { + return uris.get(0).toString(); + } + } + } catch (Exception e) { + throw new IllegalStateException("Failed to extract remote URL from GitSCM: " + e.getMessage(), e); + } + } + + throw new IllegalStateException( + "Unsupported SCM type: " + scmClassName + ". Only GitSCM is currently supported."); + } + + /** + * Builds a {@link ScmRepo} from the remote URL and applies any enterprise URL overrides. + */ + private ScmRepo buildScmRepo(String remoteUrl, String token, String scmTypeOverride, + String githubEnterpriseUrl, String gitlabUrl, String bitbucketUrl) { + ScmRepo repo = ScmRepo.parse(remoteUrl, token); + + // Apply enterprise base-URL overrides + if (repo.scmType() == ScmType.GITHUB && githubEnterpriseUrl != null && !githubEnterpriseUrl.isBlank()) { + repo = repo.withBaseUrl(githubEnterpriseUrl.stripTrailing() + "/api/v3"); + } else if (repo.scmType() == ScmType.GITLAB && gitlabUrl != null && !gitlabUrl.isBlank()) { + repo = repo.withBaseUrl(gitlabUrl.stripTrailing() + "/api/v4"); + } else if (repo.scmType() == ScmType.BITBUCKET && bitbucketUrl != null && !bitbucketUrl.isBlank()) { + repo = repo.withBaseUrl(bitbucketUrl.stripTrailing() + "/2.0"); + } + + // scmTypeOverride is informational / for future use; ScmRepo.parse already detected the type + // from the URL. If needed, a caller could re-parse with a different host mapping. + if (scmTypeOverride != null && !scmTypeOverride.isBlank()) { + LOGGER.fine("scmTypeOverride='" + scmTypeOverride + "' specified; current detection: " + repo.scmType()); + } + + return repo; + } + + /** + * Extracts the new file content from a unified diff (for "create" actions). + * Collects all lines starting with '+' (excluding the '+++' header). + */ + String extractNewContent(String unifiedDiff) { + if (unifiedDiff == null || unifiedDiff.isBlank()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + String[] lines = unifiedDiff.split("\r?\n", -1); + boolean inHunk = false; + for (String line : lines) { + if (line.startsWith("@@ ")) { + inHunk = true; + continue; + } + if (line.startsWith("+++ ") || line.startsWith("--- ")) { + continue; + } + if (inHunk && line.startsWith("+")) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(line.substring(1)); + } + } + return sb.toString(); + } + + /** + * Builds a Markdown PR body by substituting {@code {variable}} placeholders in the template. + */ + String buildPrBody(Run run, FixSuggestion suggestion, String template) { + String jobName = run.getParent().getFullName(); + String buildNumber = String.valueOf(run.getNumber()); + String explanation = suggestion.explanation() != null ? suggestion.explanation() : "No explanation provided."; + String confidence = suggestion.confidence() != null ? suggestion.confidence() : "unknown"; + String fixType = suggestion.fixType() != null ? suggestion.fixType() : "unknown"; + + StringBuilder changesSummary = new StringBuilder(); + if (suggestion.changes() != null) { + for (FixSuggestion.FileChange change : suggestion.changes()) { + changesSummary.append("- **") + .append(change.filePath()) + .append("** (") + .append(change.action()) + .append("): ") + .append(change.description() != null ? change.description() : "") + .append("\n"); + } + } + if (changesSummary.isEmpty()) { + changesSummary.append("No file changes.\n"); + } + + return template + .replace("{jobName}", jobName) + .replace("{buildNumber}", buildNumber) + .replace("{explanation}", explanation) + .replace("{changesSummary}", changesSummary.toString().stripTrailing()) + .replace("{fixType}", fixType) + .replace("{confidence}", confidence); + } + + /** + * Attempts to delete the given branch for rollback purposes. Logs but does not throw on failure. + */ + private void rollbackBranch(ScmApiClient client, String branchName, TaskListener listener) { + try { + listener.getLogger().println("[AutoFix] Rolling back branch: " + branchName); + client.deleteBranch(branchName); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to delete branch during rollback: " + branchName, e); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixResult.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixResult.java new file mode 100644 index 00000000..3a4a8bd0 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixResult.java @@ -0,0 +1,75 @@ +package io.jenkins.plugins.explain_error.autofix; + +public class AutoFixResult { + + private final AutoFixStatus status; + private final String prUrl; + private final String branchName; + private final String message; + + private AutoFixResult(AutoFixStatus status, String prUrl, String branchName, String message) { + this.status = status; + this.prUrl = prUrl; + this.branchName = branchName; + this.message = message; + } + + public AutoFixStatus getStatus() { + return status; + } + + public String getPrUrl() { + return prUrl; + } + + public String getBranchName() { + return branchName; + } + + public String getMessage() { + return message; + } + + public static AutoFixResult created(String prUrl, String branchName) { + return new AutoFixResult( + AutoFixStatus.CREATED, + prUrl, + branchName, + "Pull request created successfully: " + prUrl); + } + + public static AutoFixResult failed(String message) { + return new AutoFixResult(AutoFixStatus.FAILED, null, null, message); + } + + public static AutoFixResult notApplicable(String message) { + return new AutoFixResult(AutoFixStatus.NOT_APPLICABLE, null, null, message); + } + + public static AutoFixResult skippedLowConfidence() { + return new AutoFixResult( + AutoFixStatus.SKIPPED_LOW_CONFIDENCE, + null, + null, + "Skipped: AI confidence level was too low to apply automatic fix."); + } + + public static AutoFixResult skippedPathNotAllowed(String filePath) { + return new AutoFixResult( + AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, + null, + null, + "Skipped: file path is not allowed for automatic modification: " + filePath); + } + + public static AutoFixResult timedOut() { + return new AutoFixResult( + AutoFixStatus.TIMED_OUT, null, null, "Timed out while attempting to apply automatic fix."); + } + + @Override + public String toString() { + return "AutoFixResult{status=" + status + ", prUrl='" + prUrl + "', branchName='" + branchName + + "', message='" + message + "'}"; + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixStatus.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixStatus.java new file mode 100644 index 00000000..00d18ed7 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixStatus.java @@ -0,0 +1,10 @@ +package io.jenkins.plugins.explain_error.autofix; + +public enum AutoFixStatus { + CREATED, + FAILED, + NOT_APPLICABLE, + SKIPPED_LOW_CONFIDENCE, + SKIPPED_PATH_NOT_ALLOWED, + TIMED_OUT +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/FixAssistant.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/FixAssistant.java new file mode 100644 index 00000000..33956737 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/FixAssistant.java @@ -0,0 +1,38 @@ +package io.jenkins.plugins.explain_error.autofix; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import dev.langchain4j.service.V; + +public interface FixAssistant { + + @SystemMessage(""" + You are an expert Jenkins CI/CD engineer. You analyze build failure logs and generate structured fix suggestions. + + You MUST respond ONLY with valid JSON matching this exact schema (no other text before or after): + { + "fixable": , + "explanation": "", + "confidence": "", + "fixType": "", + "changes": [ + { + "filePath": "", + "action": "", + "unifiedDiff": "", + "description": "" + } + ] + } + + Rules: + - Only set fixable=true when confidence is "high" or "medium" + - Only suggest changes to source/config files. NEVER modify: target/, build/, dist/, node_modules/, .gradle/, lock files (package-lock.json, yarn.lock, Pipfile.lock), secrets (.env*, credentials*) + - For unifiedDiff: use standard unified diff format with @@ -line,count +line,count @@ headers + - filePath must be relative to repo root (no leading /, no ../ traversal) + - If you cannot determine a fix with at least medium confidence, set fixable=false and return an empty changes array + - Supported file types: pom.xml, build.gradle, build.gradle.kts, package.json, requirements.txt, go.mod, Gemfile, Jenkinsfile, Dockerfile, *.yaml, *.yml, *.json (config), *.properties, *.xml (config), *.java, *.py, *.js, *.ts (small targeted fixes only) + """) + @UserMessage("Jenkins build failed. Analyze and suggest a fix.\n\nError logs:\n{{errorLogs}}") + String suggestFix(@V("errorLogs") String errorLogs); +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/FixSuggestion.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/FixSuggestion.java new file mode 100644 index 00000000..dd0836e8 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/FixSuggestion.java @@ -0,0 +1,20 @@ +package io.jenkins.plugins.explain_error.autofix; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record FixSuggestion( + boolean fixable, + String explanation, + String confidence, + String fixType, + List changes) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record FileChange( + String filePath, + String action, + String unifiedDiff, + String description) {} +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java new file mode 100644 index 00000000..402fc37f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java @@ -0,0 +1,222 @@ +package io.jenkins.plugins.explain_error.autofix; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Standalone utility class that applies unified diffs to file content. + */ +public class UnifiedDiffApplier { + + private static final Pattern HUNK_HEADER = Pattern.compile( + "^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@(.*)$"); + + private UnifiedDiffApplier() {} + + /** + * Applies a unified diff string to original file content. + * + * @param originalContent the original file content + * @param diff the unified diff to apply + * @return the modified file content + * @throws IllegalArgumentException if the diff cannot be applied (e.g., context mismatch) + */ + public static String apply(String originalContent, String diff) { + // Split original into lines preserving empty trailing line behaviour + List lines = splitLines(originalContent); + + List result = new ArrayList<>(lines); + String[] diffLines = diff.split("\r?\n", -1); + + int offset = 0; // cumulative offset from previous hunk applications + + int i = 0; + while (i < diffLines.length) { + String line = diffLines[i]; + + // Skip file header lines (--- / +++) + if (line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("diff ") || line.startsWith("index ")) { + i++; + continue; + } + + Matcher m = HUNK_HEADER.matcher(line); + if (!m.matches()) { + i++; + continue; + } + + // Parse hunk header + int startOld = Integer.parseInt(m.group(1)); + int countOld = m.group(2) != null ? Integer.parseInt(m.group(2)) : 1; + // startNew and countNew not used directly beyond validation + i++; + + // Collect hunk body lines + List hunkLines = new ArrayList<>(); + while (i < diffLines.length && !diffLines[i].startsWith("@@ ") && !diffLines[i].startsWith("--- ") && !diffLines[i].startsWith("+++ ")) { + hunkLines.add(diffLines[i]); + i++; + } + + // Calculate insertion point in result (0-based) + // startOld is 1-based; apply cumulative offset. + // Special case: startOld=0 means "create new file" or "insert at beginning" — + // treat as position 0 without fuzzy matching. + int actualPos; + if (startOld == 0) { + actualPos = 0; + } else { + int expectedPos = (startOld - 1) + offset; + // Extract context lines from hunk to find actual position (fuzzy match ±3) + actualPos = findActualPosition(result, hunkLines, expectedPos, countOld); + } + if (actualPos < 0) { + throw new IllegalArgumentException( + "Cannot apply diff hunk at line " + startOld + ": context lines do not match"); + } + + // Apply the hunk: remove old lines, insert new lines + int insertionPoint = actualPos; + int removedCount = 0; + List insertLines = new ArrayList<>(); + + for (String hunkLine : hunkLines) { + if (hunkLine.startsWith("\\")) { + // "\ No newline at end of file" — skip + continue; + } + if (hunkLine.startsWith("-")) { + // Remove line: just track count + removedCount++; + } else if (hunkLine.startsWith("+")) { + insertLines.add(hunkLine.substring(1)); + } else { + // Context line: keep as-is (will be part of result after removal/insertion) + insertLines.add(hunkLine.length() > 0 ? hunkLine.substring(1) : ""); + removedCount++; + } + } + + // Remove old lines and insert new lines atomically + for (int r = 0; r < removedCount && actualPos < result.size(); r++) { + result.remove(actualPos); + } + result.addAll(actualPos, insertLines); + + // Update offset: new lines added minus old lines removed + offset += insertLines.size() - removedCount; + } + + return String.join("\n", result); + } + + /** + * Validates that a diff string is syntactically valid unified diff format. + * Checks for: valid @@ hunk headers, non-empty hunks. + * + * @return null if valid, or an error message string if invalid + */ + public static String validate(String diff) { + if (diff == null || diff.isBlank()) { + return "Diff is null or empty"; + } + + String[] lines = diff.split("\r?\n", -1); + boolean foundHunk = false; + boolean hunkHasChange = false; + + for (String line : lines) { + if (line.startsWith("@@ ")) { + // Validate format + if (!line.matches("^@@ -\\d+(?:,\\d+)? \\+\\d+(?:,\\d+)? @@.*$")) { + return "Invalid hunk header: " + line; + } + if (foundHunk && !hunkHasChange) { + return "Hunk has no changed lines (no + or - lines)"; + } + foundHunk = true; + hunkHasChange = false; + } else if (foundHunk && (line.startsWith("+") || line.startsWith("-"))) { + hunkHasChange = true; + } + } + + if (!foundHunk) { + return "No @@ hunk headers found in diff"; + } + + if (!hunkHasChange) { + return "Last hunk has no changed lines (no + or - lines)"; + } + + return null; // valid + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static List splitLines(String content) { + if (content == null || content.isEmpty()) { + return new ArrayList<>(); + } + // Split on \r\n or \n, preserving trailing empty string + String[] parts = content.split("\r?\n", -1); + List list = new ArrayList<>(); + for (String part : parts) { + list.add(part); + } + // If content ends with newline, split produces a trailing empty string — keep it + // but remove it at the end since join("\n") will reconstruct the newline + if (!list.isEmpty() && list.get(list.size() - 1).isEmpty() && content.endsWith("\n")) { + // Keep it: join("\n") on ["a","b",""] => "a\nb\n" + } + return list; + } + + /** + * Finds the actual start position in {@code result} where this hunk should be applied. + * Uses the context lines (lines starting with ' ') for validation. + * Tries exact position first, then fuzzy match ±3. + * + * @return the 0-based index into {@code result}, or -1 if not found + */ + private static int findActualPosition(List result, List hunkLines, int expectedPos, int countOld) { + // Try exact position first, then fuzzy ±3 + for (int delta = 0; delta <= 3; delta++) { + for (int sign : new int[]{0, 1, -1}) { + if (sign == 0 && delta != 0) continue; + int candidate = expectedPos + sign * delta; + if (candidate < 0 || candidate > result.size()) continue; + if (contextMatches(result, hunkLines, candidate)) { + return candidate; + } + } + } + return -1; + } + + /** + * Checks whether the context lines of the hunk match the result at the given position. + */ + private static boolean contextMatches(List result, List hunkLines, int pos) { + int resultIdx = pos; + for (String hunkLine : hunkLines) { + if (hunkLine.startsWith("\\")) continue; + if (hunkLine.startsWith("+")) continue; // new lines don't need to match + // Context line (' ') or removed line ('-') must match existing content + String expected = hunkLine.length() > 0 ? hunkLine.substring(1) : ""; + if (resultIdx >= result.size()) { + return false; + } + if (!result.get(resultIdx).equals(expected)) { + return false; + } + resultIdx++; + } + return true; + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/BitbucketApiClient.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/BitbucketApiClient.java new file mode 100644 index 00000000..3ce58a08 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/BitbucketApiClient.java @@ -0,0 +1,270 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.StringJoiner; +import java.util.UUID; + +public class BitbucketApiClient implements ScmApiClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final ScmRepo repo; + private final HttpClient client; + + public BitbucketApiClient(ScmRepo repo) { + this.repo = repo; + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + // ----------------------------------------------------------------------- + // ScmApiClient implementation + // ----------------------------------------------------------------------- + + @Override + public String getDefaultBranch() throws IOException { + JsonNode node = getJson(repoUrl()); + return node.path("mainbranch").path("name").asText(); + } + + @Override + public void validateWriteAccess() throws IOException { + // Verify at least read access first + HttpRequest readRequest = baseRequest(repoUrl()).GET().build(); + HttpResponse readResponse = sendWithRetry(readRequest, 3); + if (readResponse.statusCode() != 200) { + throw new IOException( + "Insufficient permissions: cannot access repository " + + repo.owner() + "/" + repo.repoName() + + " [" + readResponse.statusCode() + "]"); + } + + // Check write/admin permission via the user repository permissions endpoint + String fullName = repo.owner() + "/" + repo.repoName(); + String permUrl = repo.baseUrl() + "/user/permissions/repositories?q=repository.full_name%3D%22" + + URLEncoder.encode(fullName, StandardCharsets.UTF_8) + "%22"; + JsonNode permNode = getJson(permUrl); + JsonNode values = permNode.path("values"); + if (values.isArray()) { + for (JsonNode entry : values) { + String permission = entry.path("permission").asText(); + if ("write".equals(permission) || "admin".equals(permission)) { + return; + } + } + } + throw new IOException( + "Insufficient permissions: token does not have write access to " + + repo.owner() + "/" + repo.repoName()); + } + + @Override + public void createBranch(String branchName, String fromBranch) throws IOException { + // 1. Get the HEAD hash of fromBranch + JsonNode branchNode = getJson(repoUrl() + "/refs/branches/" + fromBranch); + String hash = branchNode.path("target").path("hash").asText(); + + // 2. Create new branch + ObjectNode body = MAPPER.createObjectNode(); + body.put("name", branchName); + body.putObject("target").put("hash", hash); + + HttpRequest request = baseRequest(repoUrl() + "/refs/branches") + .POST(jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "Bitbucket createBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + @Override + public String getFileContent(String filePath, String branch) throws IOException { + String url = repoUrl() + "/src/" + branch + "/" + filePath; + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() == 404) { + return null; + } + if (response.statusCode() != 200) { + throw new IOException( + "Bitbucket getFileContent failed [" + response.statusCode() + "]: " + response.body()); + } + return response.body(); + } + + @Override + public void commitFiles(String branchName, String commitMessage, Map fileContents) + throws IOException { + // Bitbucket Cloud accepts a multipart/form-data POST to /src + String boundary = "----BitbucketBoundary" + UUID.randomUUID().toString().replace("-", ""); + byte[] multipartBody = buildMultipartBody(boundary, branchName, commitMessage, fileContents); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(repoUrl() + "/src")) + .header("Authorization", "Bearer " + repo.token()) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .timeout(Duration.ofSeconds(60)) + .POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody)) + .build(); + + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "Bitbucket commitFiles failed [" + response.statusCode() + "]: " + response.body()); + } + } + + @Override + public PullRequest createPullRequest( + String title, String body, String headBranch, String baseBranch, boolean draft) throws IOException { + // Bitbucket Cloud does not support draft PRs — the draft param is ignored + ObjectNode prBody = MAPPER.createObjectNode(); + prBody.put("title", title); + prBody.put("description", body); + prBody.putObject("source").putObject("branch").put("name", headBranch); + prBody.putObject("destination").putObject("branch").put("name", baseBranch); + + HttpRequest request = baseRequest(repoUrl() + "/pullrequests") + .POST(jsonBody(prBody)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "Bitbucket createPullRequest failed [" + response.statusCode() + "]: " + response.body()); + } + JsonNode node = MAPPER.readTree(response.body()); + int number = node.path("id").asInt(); + String url = node.path("links").path("html").path("href").asText(); + return new PullRequest(number, url, headBranch, baseBranch); + } + + @Override + public void deleteBranch(String branchName) throws IOException { + String encodedName = URLEncoder.encode(branchName, StandardCharsets.UTF_8); + HttpRequest request = baseRequest(repoUrl() + "/refs/branches/" + encodedName) + .DELETE() + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 204) { + throw new IOException( + "Bitbucket deleteBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private String repoUrl() { + return repo.baseUrl() + "/repositories/" + repo.owner() + "/" + repo.repoName(); + } + + private JsonNode getJson(String url) throws IOException { + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200) { + throw new IOException( + "Bitbucket GET " + url + " failed [" + response.statusCode() + "]: " + response.body()); + } + return MAPPER.readTree(response.body()); + } + + private HttpRequest.Builder baseRequest(String url) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + repo.token()) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(30)); + } + + private static HttpRequest.BodyPublisher jsonBody(ObjectNode body) { + return HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8); + } + + /** + * Builds a multipart/form-data body for the Bitbucket /src commit endpoint. + * Fields: message, branch, and one field per file. + */ + private static byte[] buildMultipartBody( + String boundary, + String branchName, + String commitMessage, + Map fileContents) { + + String crlf = "\r\n"; + String dashes = "--"; + StringBuilder sb = new StringBuilder(); + + // message field + sb.append(dashes).append(boundary).append(crlf); + sb.append("Content-Disposition: form-data; name=\"message\"").append(crlf); + sb.append(crlf); + sb.append(commitMessage).append(crlf); + + // branch field + sb.append(dashes).append(boundary).append(crlf); + sb.append("Content-Disposition: form-data; name=\"branch\"").append(crlf); + sb.append(crlf); + sb.append(branchName).append(crlf); + + // one field per file + for (Map.Entry entry : fileContents.entrySet()) { + sb.append(dashes).append(boundary).append(crlf); + sb.append("Content-Disposition: form-data; name=\"") + .append(entry.getKey()) + .append("\"") + .append(crlf); + sb.append(crlf); + sb.append(entry.getValue()).append(crlf); + } + + // closing boundary + sb.append(dashes).append(boundary).append(dashes).append(crlf); + + return sb.toString().getBytes(StandardCharsets.UTF_8); + } + + private HttpResponse sendWithRetry(HttpRequest request, int maxRetries) throws IOException { + int attempt = 0; + long delayMs = 1000; + while (true) { + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if ((status == 429 || status >= 500) && attempt < maxRetries) { + attempt++; + sleep(delayMs); + delayMs *= 2; + continue; + } + return response; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); + } + } + } + + private static void sleep(long ms) throws IOException { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during retry backoff", e); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitHubApiClient.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitHubApiClient.java new file mode 100644 index 00000000..b5752b94 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitHubApiClient.java @@ -0,0 +1,272 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Map; +import java.util.Random; + +public class GitHubApiClient implements ScmApiClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Random RANDOM = new Random(); + + private final ScmRepo repo; + private final HttpClient client; + + public GitHubApiClient(ScmRepo repo) { + this.repo = repo; + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + // ----------------------------------------------------------------------- + // ScmApiClient implementation + // ----------------------------------------------------------------------- + + @Override + public String getDefaultBranch() throws IOException { + JsonNode node = getJson(repoUrl()); + return node.path("default_branch").asText(); + } + + @Override + public void validateWriteAccess() throws IOException { + JsonNode node = getJson(repoUrl()); + boolean pushAccess = node.path("permissions").path("push").asBoolean(false); + if (!pushAccess) { + throw new IOException("Insufficient permissions: token does not have push access to " + + repo.owner() + "/" + repo.repoName()); + } + } + + @Override + public void createBranch(String branchName, String fromBranch) throws IOException { + String sha = getRefSha("heads/" + fromBranch); + try { + doCreateRef("refs/heads/" + branchName, sha); + } catch (IOException e) { + if (e.getMessage() != null && e.getMessage().contains("422")) { + // Branch already exists – retry with a random suffix + String suffixed = branchName + "-" + randomHex4(); + doCreateRef("refs/heads/" + suffixed, sha); + } else { + throw e; + } + } + } + + @Override + public String getFileContent(String filePath, String branch) throws IOException { + String url = repoUrl() + "/contents/" + filePath + "?ref=" + branch; + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() == 404) { + return null; + } + if (response.statusCode() != 200) { + throw new IOException("GitHub getFileContent failed [" + response.statusCode() + "]: " + response.body()); + } + JsonNode node = MAPPER.readTree(response.body()); + String encoded = node.path("content").asText().replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(encoded); + return new String(decoded, StandardCharsets.UTF_8); + } + + @Override + public void commitFiles(String branchName, String commitMessage, Map fileContents) + throws IOException { + + // 1. Get current commit SHA on branch + String currentSha = getRefSha("heads/" + branchName); + + // 2. Get tree SHA of current commit + JsonNode commitNode = getJson(repoUrl() + "/git/commits/" + currentSha); + String treeSha = commitNode.path("tree").path("sha").asText(); + + // 3. Create blobs + ArrayNode treeEntries = MAPPER.createArrayNode(); + for (Map.Entry entry : fileContents.entrySet()) { + ObjectNode blobBody = MAPPER.createObjectNode(); + blobBody.put("encoding", "utf-8"); + blobBody.put("content", entry.getValue()); + JsonNode blobResponse = postJson(repoUrl() + "/git/blobs", blobBody); + String blobSha = blobResponse.path("sha").asText(); + + ObjectNode treeEntry = MAPPER.createObjectNode(); + treeEntry.put("path", entry.getKey()); + treeEntry.put("mode", "100644"); + treeEntry.put("type", "blob"); + treeEntry.put("sha", blobSha); + treeEntries.add(treeEntry); + } + + // 4. Create tree + ObjectNode treeBody = MAPPER.createObjectNode(); + treeBody.put("base_tree", treeSha); + treeBody.set("tree", treeEntries); + JsonNode newTree = postJson(repoUrl() + "/git/trees", treeBody); + String newTreeSha = newTree.path("sha").asText(); + + // 5. Create commit + ObjectNode commitBody = MAPPER.createObjectNode(); + commitBody.put("message", commitMessage); + commitBody.put("tree", newTreeSha); + commitBody.putArray("parents").add(currentSha); + JsonNode newCommit = postJson(repoUrl() + "/git/commits", commitBody); + String newCommitSha = newCommit.path("sha").asText(); + + // 6. Update ref + ObjectNode refBody = MAPPER.createObjectNode(); + refBody.put("sha", newCommitSha); + patchJson(repoUrl() + "/git/refs/heads/" + branchName, refBody); + } + + @Override + public PullRequest createPullRequest( + String title, String body, String headBranch, String baseBranch, boolean draft) throws IOException { + ObjectNode prBody = MAPPER.createObjectNode(); + prBody.put("title", title); + prBody.put("body", body); + prBody.put("head", headBranch); + prBody.put("base", baseBranch); + prBody.put("draft", draft); + JsonNode response = postJson(repoUrl() + "/pulls", prBody); + int number = response.path("number").asInt(); + String url = response.path("html_url").asText(); + return new PullRequest(number, url, headBranch, baseBranch); + } + + @Override + public void deleteBranch(String branchName) throws IOException { + HttpRequest request = baseRequest(repoUrl() + "/git/refs/heads/" + branchName) + .DELETE() + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 204 && response.statusCode() != 422) { + throw new IOException( + "GitHub deleteBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private String repoUrl() { + return repo.baseUrl() + "/repos/" + repo.owner() + "/" + repo.repoName(); + } + + private String getRefSha(String ref) throws IOException { + JsonNode node = getJson(repoUrl() + "/git/ref/" + ref); + return node.path("object").path("sha").asText(); + } + + private void doCreateRef(String refPath, String sha) throws IOException { + ObjectNode body = MAPPER.createObjectNode(); + body.put("ref", refPath); + body.put("sha", sha); + HttpRequest request = baseRequest(repoUrl() + "/git/refs") + .POST(jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 201) { + throw new IOException( + "GitHub createBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + private JsonNode getJson(String url) throws IOException { + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200) { + throw new IOException("GitHub GET " + url + " failed [" + response.statusCode() + "]: " + response.body()); + } + return MAPPER.readTree(response.body()); + } + + private JsonNode postJson(String url, ObjectNode body) throws IOException { + HttpRequest request = baseRequest(url) + .POST(jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "GitHub POST " + url + " failed [" + response.statusCode() + "]: " + response.body()); + } + return MAPPER.readTree(response.body()); + } + + private JsonNode patchJson(String url, ObjectNode body) throws IOException { + HttpRequest request = baseRequest(url) + .method("PATCH", jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200) { + throw new IOException( + "GitHub PATCH " + url + " failed [" + response.statusCode() + "]: " + response.body()); + } + return MAPPER.readTree(response.body()); + } + + private HttpRequest.Builder baseRequest(String url) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + repo.token()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(30)); + } + + private static HttpRequest.BodyPublisher jsonBody(ObjectNode body) { + return HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8); + } + + private HttpResponse sendWithRetry(HttpRequest request, int maxRetries) throws IOException { + int attempt = 0; + long delayMs = 1000; + while (true) { + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if ((status == 429 || status >= 500) && attempt < maxRetries) { + attempt++; + sleep(delayMs); + delayMs *= 2; + continue; + } + return response; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); + } + } + } + + private static void sleep(long ms) throws IOException { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during retry backoff", e); + } + } + + private static String randomHex4() { + byte[] bytes = new byte[2]; + RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitLabApiClient.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitLabApiClient.java new file mode 100644 index 00000000..cc8d8999 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/GitLabApiClient.java @@ -0,0 +1,266 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Map; +import java.util.Random; + +public class GitLabApiClient implements ScmApiClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Random RANDOM = new Random(); + + private final ScmRepo repo; + private final HttpClient client; + + // Cached project ID (resolved lazily) + private volatile String cachedProjectId; + + public GitLabApiClient(ScmRepo repo) { + this.repo = repo; + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + // ----------------------------------------------------------------------- + // ScmApiClient implementation + // ----------------------------------------------------------------------- + + @Override + public String getDefaultBranch() throws IOException { + JsonNode node = getJson(projectUrl()); + return node.path("default_branch").asText(); + } + + @Override + public void validateWriteAccess() throws IOException { + JsonNode node = getJson(projectUrl() + "?simple=true"); + JsonNode perms = node.path("permissions"); + int projectLevel = + perms.path("project_access").path("access_level").asInt(0); + int groupLevel = + perms.path("group_access").path("access_level").asInt(0); + if (projectLevel < 30 && groupLevel < 30) { + throw new IOException( + "Insufficient permissions: token does not have developer (write) access to " + + repo.owner() + "/" + repo.repoName()); + } + } + + @Override + public void createBranch(String branchName, String fromBranch) throws IOException { + try { + doCreateBranch(branchName, fromBranch); + } catch (IOException e) { + if (e.getMessage() != null && e.getMessage().contains("400")) { + // Branch may already exist – retry with a random suffix + String suffixed = branchName + "-" + randomHex4(); + doCreateBranch(suffixed, fromBranch); + } else { + throw e; + } + } + } + + @Override + public String getFileContent(String filePath, String branch) throws IOException { + String encodedPath = URLEncoder.encode(filePath, StandardCharsets.UTF_8); + String url = projectIdUrl() + "/repository/files/" + encodedPath + "?ref=" + branch; + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() == 404) { + return null; + } + if (response.statusCode() != 200) { + throw new IOException( + "GitLab getFileContent failed [" + response.statusCode() + "]: " + response.body()); + } + JsonNode node = MAPPER.readTree(response.body()); + String encoded = node.path("content").asText().replaceAll("\\s", ""); + byte[] decoded = java.util.Base64.getDecoder().decode(encoded); + return new String(decoded, StandardCharsets.UTF_8); + } + + @Override + public void commitFiles(String branchName, String commitMessage, Map fileContents) + throws IOException { + ArrayNode actions = MAPPER.createArrayNode(); + for (Map.Entry entry : fileContents.entrySet()) { + String existingContent = getFileContent(entry.getKey(), branchName); + String actionType = existingContent != null ? "update" : "create"; + + ObjectNode action = MAPPER.createObjectNode(); + action.put("action", actionType); + action.put("file_path", entry.getKey()); + action.put("content", entry.getValue()); + actions.add(action); + } + + ObjectNode body = MAPPER.createObjectNode(); + body.put("branch", branchName); + body.put("commit_message", commitMessage); + body.set("actions", actions); + + HttpRequest request = baseRequest(projectIdUrl() + "/repository/commits") + .POST(jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "GitLab commitFiles failed [" + response.statusCode() + "]: " + response.body()); + } + } + + @Override + public PullRequest createPullRequest( + String title, String body, String headBranch, String baseBranch, boolean draft) throws IOException { + ObjectNode mrBody = MAPPER.createObjectNode(); + mrBody.put("source_branch", headBranch); + mrBody.put("target_branch", baseBranch); + mrBody.put("title", title); + mrBody.put("description", body); + mrBody.put("draft", draft); + + HttpRequest request = baseRequest(projectIdUrl() + "/merge_requests") + .POST(jsonBody(mrBody)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "GitLab createMergeRequest failed [" + response.statusCode() + "]: " + response.body()); + } + JsonNode node = MAPPER.readTree(response.body()); + int number = node.path("iid").asInt(); + String url = node.path("web_url").asText(); + return new PullRequest(number, url, headBranch, baseBranch); + } + + @Override + public void deleteBranch(String branchName) throws IOException { + String encodedName = URLEncoder.encode(branchName, StandardCharsets.UTF_8); + HttpRequest request = baseRequest(projectIdUrl() + "/repository/branches/" + encodedName) + .DELETE() + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 204) { + throw new IOException( + "GitLab deleteBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** URL-encoded "owner/repoName" path for the GitLab projects API. */ + private String urlEncodedProjectPath() { + String path = repo.owner() + "/" + repo.repoName(); + return URLEncoder.encode(path, StandardCharsets.UTF_8); + } + + /** Base project URL using the namespace path (for initial lookup). */ + private String projectUrl() { + return repo.baseUrl() + "/projects/" + urlEncodedProjectPath(); + } + + /** Base project URL using the numeric project ID (for all mutating calls). */ + private String projectIdUrl() throws IOException { + return repo.baseUrl() + "/projects/" + getProjectId(); + } + + /** + * Fetches (and caches) the numeric GitLab project ID. + */ + private String getProjectId() throws IOException { + if (cachedProjectId != null) { + return cachedProjectId; + } + JsonNode node = getJson(projectUrl()); + cachedProjectId = node.path("id").asText(); + return cachedProjectId; + } + + private void doCreateBranch(String branchName, String fromBranch) throws IOException { + ObjectNode body = MAPPER.createObjectNode(); + body.put("branch", branchName); + body.put("ref", fromBranch); + + HttpRequest request = baseRequest(projectIdUrl() + "/repository/branches") + .POST(jsonBody(body)) + .build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200 && response.statusCode() != 201) { + throw new IOException( + "GitLab createBranch failed [" + response.statusCode() + "]: " + response.body()); + } + } + + private JsonNode getJson(String url) throws IOException { + HttpRequest request = baseRequest(url).GET().build(); + HttpResponse response = sendWithRetry(request, 3); + if (response.statusCode() != 200) { + throw new IOException("GitLab GET " + url + " failed [" + response.statusCode() + "]: " + response.body()); + } + return MAPPER.readTree(response.body()); + } + + private HttpRequest.Builder baseRequest(String url) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + repo.token()) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(30)); + } + + private static HttpRequest.BodyPublisher jsonBody(ObjectNode body) { + return HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8); + } + + private HttpResponse sendWithRetry(HttpRequest request, int maxRetries) throws IOException { + int attempt = 0; + long delayMs = 1000; + while (true) { + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if ((status == 429 || status >= 500) && attempt < maxRetries) { + attempt++; + sleep(delayMs); + delayMs *= 2; + continue; + } + return response; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("HTTP request interrupted", e); + } + } + } + + private static void sleep(long ms) throws IOException { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted during retry backoff", e); + } + } + + private static String randomHex4() { + byte[] bytes = new byte[2]; + RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/PullRequest.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/PullRequest.java new file mode 100644 index 00000000..5b94d412 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/PullRequest.java @@ -0,0 +1,7 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +public record PullRequest( + int number, + String url, + String headBranch, + String baseBranch) {} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmApiClient.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmApiClient.java new file mode 100644 index 00000000..fcc958a0 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmApiClient.java @@ -0,0 +1,73 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +import java.io.IOException; +import java.util.Map; + +public interface ScmApiClient { + + /** + * Returns the default branch name (e.g., "main", "master"). + * + * @return the default branch name + * @throws IOException if the API call fails + */ + String getDefaultBranch() throws IOException; + + /** + * Creates a new branch from the given base branch. + * + * @param branchName the name of the new branch + * @param fromBranch the branch to create from + * @throws IOException if the API call fails + */ + void createBranch(String branchName, String fromBranch) throws IOException; + + /** + * Returns the current file content as a UTF-8 string, or null if the file does not exist. + * + * @param filePath the file path relative to repository root + * @param branch the branch to read from + * @return file content as a string, or null if not found + * @throws IOException if the API call fails for a reason other than 404 + */ + String getFileContent(String filePath, String branch) throws IOException; + + /** + * Atomically commits multiple file changes to the given branch. + * + * @param branchName the branch to commit to + * @param commitMessage the commit message + * @param fileContents map of filePath to new complete file content (not diffs) + * @throws IOException if the API call fails + */ + void commitFiles(String branchName, String commitMessage, Map fileContents) throws IOException; + + /** + * Creates a pull request and returns the created PR. + * + * @param title the pull request title + * @param body the pull request description body + * @param headBranch the source branch + * @param baseBranch the target branch + * @param draft whether to create as a draft PR + * @return the created PullRequest + * @throws IOException if the API call fails + */ + PullRequest createPullRequest(String title, String body, String headBranch, String baseBranch, boolean draft) + throws IOException; + + /** + * Deletes a branch (for cleanup/rollback). + * + * @param branchName the branch to delete + * @throws IOException if the API call fails + */ + void deleteBranch(String branchName) throws IOException; + + /** + * Validates that the token has write access to the repository. + * + * @throws IOException with a clear message if the token does not have push/write access + */ + void validateWriteAccess() throws IOException; +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmClientFactory.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmClientFactory.java new file mode 100644 index 00000000..c6ea5d8b --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmClientFactory.java @@ -0,0 +1,14 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +public class ScmClientFactory { + + private ScmClientFactory() {} + + public static ScmApiClient create(ScmRepo repo) { + return switch (repo.scmType()) { + case GITHUB -> new GitHubApiClient(repo); + case GITLAB -> new GitLabApiClient(repo); + case BITBUCKET -> new BitbucketApiClient(repo); + }; + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java new file mode 100644 index 00000000..5198e5f4 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java @@ -0,0 +1,78 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record ScmRepo(ScmType scmType, String baseUrl, String owner, String repoName, String token) { + + // SSH: git@github.com:owner/repo.git + private static final Pattern SSH_PATTERN = + Pattern.compile("git@([^:]+):([^/]+)/(.+?)(?:\\.git)?$"); + + // HTTPS: https://github.com/owner/repo.git or http://... + private static final Pattern HTTPS_PATTERN = + Pattern.compile("https?://(?:[^@]+@)?([^/]+)/([^/]+)/(.+?)(?:\\.git)?$"); + + /** + * Parses a remote URL (SSH or HTTPS) and detects the SCM type, owner, and repo name. + * + * @param remoteUrl the remote URL (SSH or HTTPS format) + * @param token the authentication token (plaintext) + * @return a populated ScmRepo + * @throws IllegalArgumentException if the URL cannot be parsed or the SCM type cannot be determined + */ + public static ScmRepo parse(String remoteUrl, String token) { + if (remoteUrl == null || remoteUrl.isBlank()) { + throw new IllegalArgumentException("Remote URL must not be null or blank"); + } + + String url = remoteUrl.trim(); + String host; + String owner; + String repoName; + + Matcher sshMatcher = SSH_PATTERN.matcher(url); + Matcher httpsMatcher = HTTPS_PATTERN.matcher(url); + + if (sshMatcher.matches()) { + host = sshMatcher.group(1).toLowerCase(); + owner = sshMatcher.group(2); + repoName = sshMatcher.group(3); + } else if (httpsMatcher.matches()) { + host = httpsMatcher.group(1).toLowerCase(); + owner = httpsMatcher.group(2); + repoName = httpsMatcher.group(3); + } else { + throw new IllegalArgumentException("Cannot parse remote URL: " + remoteUrl); + } + + ScmType scmType; + String baseUrl; + + if (host.contains("github.com")) { + scmType = ScmType.GITHUB; + baseUrl = "https://api.github.com"; + } else if (host.contains("gitlab.com")) { + scmType = ScmType.GITLAB; + baseUrl = "https://gitlab.com/api/v4"; + } else if (host.contains("bitbucket.org")) { + scmType = ScmType.BITBUCKET; + baseUrl = "https://api.bitbucket.org/2.0"; + } else { + throw new IllegalArgumentException( + "Cannot detect SCM type from host '" + host + "' in URL: " + remoteUrl); + } + + return new ScmRepo(scmType, baseUrl, owner, repoName, token); + } + + /** + * Returns a new ScmRepo with the baseUrl overridden (for enterprise instances). + * + * @param baseUrl the new API base URL + * @return a new ScmRepo with the updated baseUrl + */ + public ScmRepo withBaseUrl(String baseUrl) { + return new ScmRepo(this.scmType, baseUrl, this.owner, this.repoName, this.token); + } +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmType.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmType.java new file mode 100644 index 00000000..c3895d74 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmType.java @@ -0,0 +1,7 @@ +package io.jenkins.plugins.explain_error.autofix.scm; + +public enum ScmType { + GITHUB, + GITLAB, + BITBUCKET +} diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/BaseAIProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/BaseAIProvider.java index c735d97b..d4996823 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/provider/BaseAIProvider.java +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/BaseAIProvider.java @@ -36,6 +36,8 @@ public BaseAIProvider(String url, String model) { public abstract Assistant createAssistant(); + public abstract io.jenkins.plugins.explain_error.autofix.FixAssistant createFixAssistant(); + public abstract boolean isNotValid(@CheckForNull TaskListener listener); public String getUrl() { diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/BedrockProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/BedrockProvider.java index 20ee2044..903aafc9 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/provider/BedrockProvider.java +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/BedrockProvider.java @@ -39,6 +39,17 @@ public String getRegion() { @Override public Assistant createAssistant() { + ChatModel model = buildChatModel(); + return AiServices.create(Assistant.class, model); + } + + @Override + public io.jenkins.plugins.explain_error.autofix.FixAssistant createFixAssistant() { + ChatModel model = buildChatModel(); + return AiServices.create(io.jenkins.plugins.explain_error.autofix.FixAssistant.class, model); + } + + private ChatModel buildChatModel() { var builder = BedrockChatModel.builder() .modelId(getModel()) .defaultRequestParameters( @@ -53,8 +64,7 @@ public Assistant createAssistant() { builder.region(Region.of(region)); } - ChatModel model = builder.build(); - return AiServices.create(Assistant.class, model); + return builder.build(); } @Override diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/GeminiProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/GeminiProvider.java index 6f26f328..64882bb3 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/provider/GeminiProvider.java +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/GeminiProvider.java @@ -38,7 +38,18 @@ public Secret getApiKey() { @Override public Assistant createAssistant() { - ChatModel model = GoogleAiGeminiChatModel.builder() + ChatModel model = buildChatModel(); + return AiServices.create(Assistant.class, model); + } + + @Override + public io.jenkins.plugins.explain_error.autofix.FixAssistant createFixAssistant() { + ChatModel model = buildChatModel(); + return AiServices.create(io.jenkins.plugins.explain_error.autofix.FixAssistant.class, model); + } + + private ChatModel buildChatModel() { + return GoogleAiGeminiChatModel.builder() .baseUrl(Util.fixEmptyAndTrim(getUrl())) // Will use default if null .apiKey(getApiKey().getPlainText()) .modelName(getModel()) @@ -47,8 +58,6 @@ public Assistant createAssistant() { .logRequests(LOGGER.isLoggable(Level.FINE)) .logResponses(LOGGER.isLoggable(Level.FINE)) .build(); - - return AiServices.create(Assistant.class, model); } @Override diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/OllamaProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/OllamaProvider.java index dd30a128..7dfdde99 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/provider/OllamaProvider.java +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/OllamaProvider.java @@ -31,7 +31,18 @@ public OllamaProvider(String url, String model) { @Override public Assistant createAssistant() { - ChatModel model = OllamaChatModel.builder() + ChatModel model = buildChatModel(); + return AiServices.create(Assistant.class, model); + } + + @Override + public io.jenkins.plugins.explain_error.autofix.FixAssistant createFixAssistant() { + ChatModel model = buildChatModel(); + return AiServices.create(io.jenkins.plugins.explain_error.autofix.FixAssistant.class, model); + } + + private ChatModel buildChatModel() { + return OllamaChatModel.builder() .baseUrl(getUrl()) .modelName(getModel()) .temperature(0.3) @@ -40,7 +51,6 @@ public Assistant createAssistant() { .logRequests(LOGGER.isLoggable(Level.FINE)) .logResponses(LOGGER.isLoggable(Level.FINE)) .build(); - return AiServices.create(Assistant.class, model); } @Override diff --git a/src/main/java/io/jenkins/plugins/explain_error/provider/OpenAIProvider.java b/src/main/java/io/jenkins/plugins/explain_error/provider/OpenAIProvider.java index e299b451..186e620e 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/provider/OpenAIProvider.java +++ b/src/main/java/io/jenkins/plugins/explain_error/provider/OpenAIProvider.java @@ -40,7 +40,18 @@ public Secret getApiKey() { @Override public Assistant createAssistant() { - ChatModel model = OpenAiChatModel.builder() + ChatModel model = buildChatModel(); + return AiServices.create(Assistant.class, model); + } + + @Override + public io.jenkins.plugins.explain_error.autofix.FixAssistant createFixAssistant() { + ChatModel model = buildChatModel(); + return AiServices.create(io.jenkins.plugins.explain_error.autofix.FixAssistant.class, model); + } + + private ChatModel buildChatModel() { + return OpenAiChatModel.builder() .baseUrl(Util.fixEmptyAndTrim(getUrl())) // Will use default if null .apiKey(getApiKey().getPlainText()) .modelName(getModel()) @@ -50,8 +61,6 @@ public Assistant createAssistant() { .logRequests(LOGGER.isLoggable(Level.FINE)) .logResponses(LOGGER.isLoggable(Level.FINE)) .build(); - - return AiServices.create(Assistant.class, model); } @Override diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly index f54552b1..982afeb1 100644 --- a/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly +++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly @@ -1,11 +1,11 @@ - - - @@ -14,7 +14,7 @@ description="Preferred response language (e.g., English, 中文, 日本語, Español, etc). Leave empty for English."> - + @@ -29,4 +29,51 @@ description="Regular expression matched against downstream job full names. Used only when downstream collection is enabled."> + + + + + + + + + Auto-detect + GitHub + GitLab + Bitbucket + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.jelly b/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.jelly new file mode 100644 index 00000000..8eb53b7c --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.jelly @@ -0,0 +1,22 @@ + + + + + +

AI Auto-Fix

+ +

${it.message}

+ + +

Branch: ${it.branchName}

+
+

Generated on: ${it.formattedTimestamp}

+
+ + + diff --git a/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.properties b/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.properties new file mode 100644 index 00000000..343ab5c8 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/explain_error/autofix/AutoFixAction/index.properties @@ -0,0 +1 @@ +aiAutoFix=AI Auto-Fix diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/GitHubApiClientTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/GitHubApiClientTest.java new file mode 100644 index 00000000..b7625def --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/GitHubApiClientTest.java @@ -0,0 +1,219 @@ +package io.jenkins.plugins.explain_error.autofix; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.jenkins.plugins.explain_error.autofix.scm.GitHubApiClient; +import io.jenkins.plugins.explain_error.autofix.scm.PullRequest; +import io.jenkins.plugins.explain_error.autofix.scm.ScmRepo; +import io.jenkins.plugins.explain_error.autofix.scm.ScmType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +class GitHubApiClientTest { + + private WireMockServer server; + private GitHubApiClient client; + + @BeforeEach + void setUp() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + configureFor("localhost", server.port()); + ScmRepo repo = new ScmRepo( + ScmType.GITHUB, + "http://localhost:" + server.port(), + "owner", + "repo", + "test-token"); + client = new GitHubApiClient(repo); + } + + @AfterEach + void tearDown() { + server.stop(); + } + + // ----------------------------------------------------------------------- + // getDefaultBranch + // ----------------------------------------------------------------------- + + @Test + void getDefaultBranch_success() throws Exception { + stubFor(get(urlEqualTo("/repos/owner/repo")) + .willReturn(okJson("{\"default_branch\": \"main\", \"permissions\": {\"push\": true}}"))); + + assertEquals("main", client.getDefaultBranch()); + } + + // ----------------------------------------------------------------------- + // validateWriteAccess + // ----------------------------------------------------------------------- + + @Test + void validateWriteAccess_sufficient() { + stubFor(get(urlEqualTo("/repos/owner/repo")) + .willReturn(okJson("{\"default_branch\": \"main\", \"permissions\": {\"push\": true}}"))); + + assertDoesNotThrow(() -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_insufficient() { + stubFor(get(urlEqualTo("/repos/owner/repo")) + .willReturn(okJson("{\"default_branch\": \"main\", \"permissions\": {\"push\": false}}"))); + + assertThrows(Exception.class, () -> client.validateWriteAccess()); + } + + // ----------------------------------------------------------------------- + // createBranch + // ----------------------------------------------------------------------- + + @Test + void createBranch_success() throws Exception { + stubFor(get(urlEqualTo("/repos/owner/repo/git/ref/heads/main")) + .willReturn(okJson("{\"object\": {\"sha\": \"abc123\"}}"))); + stubFor(post(urlEqualTo("/repos/owner/repo/git/refs")) + .willReturn(aResponse().withStatus(201).withBody("{}"))); + + assertDoesNotThrow(() -> client.createBranch("fix/test", "main")); + } + + @Test + void createBranch_collision_retries() throws Exception { + stubFor(get(urlEqualTo("/repos/owner/repo/git/ref/heads/main")) + .willReturn(okJson("{\"object\": {\"sha\": \"abc123\"}}"))); + + // First POST returns 422 (collision); second POST succeeds + stubFor(post(urlEqualTo("/repos/owner/repo/git/refs")) + .inScenario("collision") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(422).withBody("{\"message\": \"Reference already exists\"}")) + .willSetStateTo("retry")); + stubFor(post(urlEqualTo("/repos/owner/repo/git/refs")) + .inScenario("collision") + .whenScenarioStateIs("retry") + .willReturn(aResponse().withStatus(201).withBody("{}"))); + + assertDoesNotThrow(() -> client.createBranch("fix/test", "main")); + } + + // ----------------------------------------------------------------------- + // commitFiles (atomic via Trees API) + // ----------------------------------------------------------------------- + + @Test + void commitFiles_atomic_success() throws Exception { + stubFor(get(urlEqualTo("/repos/owner/repo/git/ref/heads/fix-branch")) + .willReturn(okJson("{\"object\": {\"sha\": \"commit123\"}}"))); + stubFor(get(urlEqualTo("/repos/owner/repo/git/commits/commit123")) + .willReturn(okJson("{\"tree\": {\"sha\": \"tree123\"}}"))); + stubFor(post(urlEqualTo("/repos/owner/repo/git/blobs")) + .willReturn(aResponse().withStatus(201).withBody("{\"sha\": \"blob123\"}"))); + stubFor(post(urlEqualTo("/repos/owner/repo/git/trees")) + .willReturn(aResponse().withStatus(201).withBody("{\"sha\": \"newtree123\"}"))); + stubFor(post(urlEqualTo("/repos/owner/repo/git/commits")) + .willReturn(aResponse().withStatus(201).withBody("{\"sha\": \"newcommit123\"}"))); + stubFor(patch(urlEqualTo("/repos/owner/repo/git/refs/heads/fix-branch")) + .willReturn(okJson("{}"))); + + assertDoesNotThrow(() -> client.commitFiles("fix-branch", "fix: test commit", Map.of("pom.xml", ""))); + } + + // ----------------------------------------------------------------------- + // Rate-limit retry + // ----------------------------------------------------------------------- + + @Test + void rateLimitRetry() throws Exception { + // First call returns 429, second call succeeds + stubFor(get(urlEqualTo("/repos/owner/repo")) + .inScenario("rateLimit") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(429).withHeader("Retry-After", "1")) + .willSetStateTo("retry1")); + stubFor(get(urlEqualTo("/repos/owner/repo")) + .inScenario("rateLimit") + .whenScenarioStateIs("retry1") + .willReturn(okJson("{\"default_branch\": \"main\", \"permissions\": {\"push\": true}}"))); + + assertEquals("main", client.getDefaultBranch()); + } + + // ----------------------------------------------------------------------- + // deleteBranch + // ----------------------------------------------------------------------- + + @Test + void deleteBranch_success() throws Exception { + stubFor(delete(urlEqualTo("/repos/owner/repo/git/refs/heads/fix-branch")) + .willReturn(aResponse().withStatus(204))); + + assertDoesNotThrow(() -> client.deleteBranch("fix-branch")); + } + + @Test + void deleteBranch_alreadyGone_noThrow() throws Exception { + // 422 is treated as "already deleted / not found" by the client + stubFor(delete(urlEqualTo("/repos/owner/repo/git/refs/heads/fix-branch")) + .willReturn(aResponse().withStatus(422).withBody("{\"message\": \"Reference does not exist\"}"))); + + assertDoesNotThrow(() -> client.deleteBranch("fix-branch")); + } + + // ----------------------------------------------------------------------- + // createPullRequest + // ----------------------------------------------------------------------- + + @Test + void createPullRequest_success() throws Exception { + stubFor(post(urlEqualTo("/repos/owner/repo/pulls")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"number\": 42, \"html_url\": \"https://github.com/owner/repo/pull/42\"}"))); + + PullRequest pr = client.createPullRequest("fix: test", "body", "fix-branch", "main", false); + assertEquals(42, pr.number()); + assertEquals("https://github.com/owner/repo/pull/42", pr.url()); + } + + @Test + void createPullRequest_draft() throws Exception { + stubFor(post(urlEqualTo("/repos/owner/repo/pulls")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"number\": 7, \"html_url\": \"https://github.com/owner/repo/pull/7\"}"))); + + PullRequest pr = client.createPullRequest("draft: test", "body", "fix-branch", "main", true); + assertEquals(7, pr.number()); + } + + // ----------------------------------------------------------------------- + // getFileContent + // ----------------------------------------------------------------------- + + @Test + void getFileContent_found() throws Exception { + // Base64 encoding of "hello world\n" + String encoded = java.util.Base64.getEncoder().encodeToString("hello world\n".getBytes()); + stubFor(get(urlPathEqualTo("/repos/owner/repo/contents/pom.xml")) + .willReturn(okJson("{\"content\": \"" + encoded + "\"}"))); + + String content = client.getFileContent("pom.xml", "main"); + assertEquals("hello world\n", content); + } + + @Test + void getFileContent_notFound_returnsNull() throws Exception { + stubFor(get(urlPathEqualTo("/repos/owner/repo/contents/missing.txt")) + .willReturn(aResponse().withStatus(404).withBody("{\"message\": \"Not Found\"}"))); + + assertNull(client.getFileContent("missing.txt", "main")); + } +} diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplierTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplierTest.java new file mode 100644 index 00000000..19aad662 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplierTest.java @@ -0,0 +1,159 @@ +package io.jenkins.plugins.explain_error.autofix; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class UnifiedDiffApplierTest { + + // ----------------------------------------------------------------------- + // apply() — happy paths + // ----------------------------------------------------------------------- + + @Test + void applySimpleAddition() { + String original = "line1\nline2\nline3\n"; + String diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,4 @@\n line1\n line2\n+added line\n line3\n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertEquals("line1\nline2\nadded line\nline3\n", result); + } + + @Test + void applySimpleRemoval() { + String original = "line1\nline2\nline3\n"; + String diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,2 @@\n line1\n-line2\n line3\n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertEquals("line1\nline3\n", result); + } + + @Test + void applySimpleModification() { + String original = "line1\nold line\nline3\n"; + String diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertEquals("line1\nnew line\nline3\n", result); + } + + @Test + void applyMultipleHunks() { + String original = "alpha\nbeta\ngamma\ndelta\nepsilon\n"; + // First hunk modifies line 1, second hunk modifies line 5 + String diff = "--- a/file.txt\n+++ b/file.txt\n" + + "@@ -1,2 +1,2 @@\n-alpha\n+ALPHA\n beta\n" + + "@@ -4,2 +4,2 @@\n delta\n-epsilon\n+EPSILON\n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertTrue(result.contains("ALPHA")); + assertTrue(result.contains("EPSILON")); + assertFalse(result.contains("alpha")); + assertFalse(result.contains("epsilon")); + } + + @Test + void applyPomXmlDependencyAddition() { + String original = " \n" + + " \n" + + " junit\n" + + " \n" + + " \n"; + String diff = "--- a/pom.xml\n+++ b/pom.xml\n" + + "@@ -1,5 +1,9 @@\n" + + " \n" + + " \n" + + " junit\n" + + " \n" + + "+ \n" + + "+ mockito\n" + + "+ mockito-core\n" + + "+ \n" + + " \n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertTrue(result.contains("mockito-core")); + assertTrue(result.contains("junit")); + } + + @Test + void applyToEmptyFile() { + String original = ""; + String diff = "--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1,2 @@\n+line1\n+line2\n"; + // Should not throw; result contains the added lines + String result = UnifiedDiffApplier.apply(original, diff); + assertTrue(result.contains("line1")); + assertTrue(result.contains("line2")); + } + + @Test + void applyAdditionAtEndOfFile() { + String original = "existing\n"; + String diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,2 @@\n existing\n+appended\n"; + String result = UnifiedDiffApplier.apply(original, diff); + assertTrue(result.contains("appended")); + assertTrue(result.contains("existing")); + } + + // ----------------------------------------------------------------------- + // apply() — error paths + // ----------------------------------------------------------------------- + + @Test + void applyThrowsOnContextMismatch() { + String original = "line1\nline2\nline3\n"; + // Diff expects "lineX" at position 1 but original has "line1" + String diff = "--- a/f\n+++ b/f\n@@ -1,1 +1,2 @@\n lineX\n+new\n"; + assertThrows(IllegalArgumentException.class, () -> UnifiedDiffApplier.apply(original, diff)); + } + + // ----------------------------------------------------------------------- + // validate() — valid cases + // ----------------------------------------------------------------------- + + @Test + void validateValidDiff() { + String diff = "--- a/f\n+++ b/f\n@@ -1,1 +1,2 @@\n context\n+new line\n"; + assertNull(UnifiedDiffApplier.validate(diff)); + } + + @Test + void validateValidDiffWithOnlyAdditions() { + String diff = "--- a/f\n+++ b/f\n@@ -0,0 +1,3 @@\n+line1\n+line2\n+line3\n"; + assertNull(UnifiedDiffApplier.validate(diff)); + } + + @Test + void validateValidDiffWithMultipleHunks() { + String diff = "--- a/f\n+++ b/f\n" + + "@@ -1,1 +1,2 @@\n context\n+added\n" + + "@@ -5,1 +6,1 @@\n-old\n+new\n"; + assertNull(UnifiedDiffApplier.validate(diff)); + } + + // ----------------------------------------------------------------------- + // validate() — invalid cases + // ----------------------------------------------------------------------- + + @Test + void validateInvalidDiff_noHunks() { + assertNotNull(UnifiedDiffApplier.validate("--- a/f\n+++ b/f\nno hunks here")); + } + + @Test + void validateInvalidDiff_malformedHunkHeader() { + assertNotNull(UnifiedDiffApplier.validate("--- a/f\n+++ b/f\n@@ bad header @@\n+line\n")); + } + + @Test + void validateNullDiff() { + assertNotNull(UnifiedDiffApplier.validate(null)); + } + + @Test + void validateBlankDiff() { + assertNotNull(UnifiedDiffApplier.validate(" ")); + } + + @Test + void validateHunkWithNoChangedLines() { + // A hunk header that is followed by only context lines (no + or -) + String diff = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n context1\n context2\n"; + assertNotNull(UnifiedDiffApplier.validate(diff)); + } +} From c236a4ca70a1cd0547cb165bea628a79240952d3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 21 Mar 2026 20:45:09 +0200 Subject: [PATCH 02/14] =?UTF-8?q?fix(qa):=20ISSUE-001=20=E2=80=94=20null?= =?UTF-8?q?=20dereference=20in=20validateFilePath=20when=20path=20has=20no?= =?UTF-8?q?=20filename=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/explain_error/autofix/AutoFixOrchestrator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index 38b60846..64a2ee91 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -364,7 +364,8 @@ private String validateFilePath(String filePath, List allowedPathGlobs) for (String glob : allowedPathGlobs) { try { PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); - if (matcher.matches(p) || matcher.matches(Path.of(p.getFileName().toString()))) { + Path fileName = p.getFileName(); + if (matcher.matches(p) || (fileName != null && matcher.matches(fileName))) { matched = true; break; } From 57d7fcd5c6c9edf422b964310dc09c3b3d241726 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 08:56:16 +0200 Subject: [PATCH 03/14] test(qa): add AutoFixOrchestratorTest (paths 1,2,8) and GitLabApiClientTest (path 7b) --- .../autofix/AutoFixOrchestratorTest.java | 315 ++++++++++++++++++ .../autofix/GitLabApiClientTest.java | 269 +++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/GitLabApiClientTest.java diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java new file mode 100644 index 00000000..7921e056 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java @@ -0,0 +1,315 @@ +package io.jenkins.plugins.explain_error.autofix; + +import hudson.model.Job; +import hudson.model.Run; +import hudson.model.TaskListener; +import io.jenkins.plugins.explain_error.provider.BaseAIProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.PrintStream; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AutoFixOrchestratorTest { + + private AutoFixOrchestrator orchestrator; + + // Shared mocks for attemptAutoFix paths + private Run run; + private BaseAIProvider aiProvider; + private FixAssistant fixAssistant; + private TaskListener listener; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + orchestrator = new AutoFixOrchestrator(); + + run = mock(Run.class); + aiProvider = mock(BaseAIProvider.class); + fixAssistant = mock(FixAssistant.class); + listener = mock(TaskListener.class); + + PrintStream printStream = mock(PrintStream.class); + when(listener.getLogger()).thenReturn(printStream); + when(aiProvider.createFixAssistant()).thenReturn(fixAssistant); + } + + // ----------------------------------------------------------------------- + // parseFixSuggestion — happy paths + // ----------------------------------------------------------------------- + + @Test + void parseFixSuggestion_fixableTrue() throws IOException { + String json = """ + { + "fixable": true, + "explanation": "Missing dependency", + "confidence": "high", + "fixType": "dependency", + "changes": [] + } + """; + FixSuggestion suggestion = orchestrator.parseFixSuggestion(json); + assertTrue(suggestion.fixable()); + assertEquals("high", suggestion.confidence()); + assertEquals("dependency", suggestion.fixType()); + } + + @Test + void parseFixSuggestion_fixableFalse() throws IOException { + String json = "{\"fixable\": false, \"explanation\": \"Unknown error\", \"confidence\": \"low\", \"fixType\": \"unknown\", \"changes\": []}"; + FixSuggestion suggestion = orchestrator.parseFixSuggestion(json); + assertFalse(suggestion.fixable()); + assertEquals("low", suggestion.confidence()); + } + + @Test + void parseFixSuggestion_withPreamble_extractsJsonBlock() throws IOException { + // AI sometimes prefixes the JSON with prose; the parser should strip it + String raw = "Sure! Here is the fix:\n{\"fixable\": false, \"confidence\": \"low\", \"fixType\": \"unknown\", \"changes\": []}"; + FixSuggestion suggestion = orchestrator.parseFixSuggestion(raw); + assertFalse(suggestion.fixable()); + } + + @Test + void parseFixSuggestion_emptyResponse_throws() { + assertThrows(IOException.class, () -> orchestrator.parseFixSuggestion("")); + } + + @Test + void parseFixSuggestion_blankResponse_throws() { + assertThrows(IOException.class, () -> orchestrator.parseFixSuggestion(" ")); + } + + @Test + void parseFixSuggestion_noJsonObject_throws() { + assertThrows(IOException.class, () -> orchestrator.parseFixSuggestion("no braces here")); + } + + // ----------------------------------------------------------------------- + // extractNewContent + // ----------------------------------------------------------------------- + + @Test + void extractNewContent_returnsAddedLines() { + String diff = "--- a/NewFile.java\n+++ b/NewFile.java\n@@ -0,0 +1,3 @@\n+line one\n+line two\n+line three\n"; + String result = orchestrator.extractNewContent(diff); + assertTrue(result.contains("line one")); + assertTrue(result.contains("line two")); + assertTrue(result.contains("line three")); + } + + @Test + void extractNewContent_ignoresContextAndRemovalLines() { + String diff = "--- a/f\n+++ b/f\n@@ -1,3 +1,3 @@\n context\n-removed\n+added\n"; + String result = orchestrator.extractNewContent(diff); + assertTrue(result.contains("added")); + assertFalse(result.contains("removed")); + assertFalse(result.contains("context")); + } + + @Test + void extractNewContent_nullDiff_returnsEmpty() { + assertEquals("", orchestrator.extractNewContent(null)); + } + + @Test + void extractNewContent_blankDiff_returnsEmpty() { + assertEquals("", orchestrator.extractNewContent(" ")); + } + + // ----------------------------------------------------------------------- + // buildPrBody + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void buildPrBody_substitutesAllPlaceholders() { + Job job = mock(Job.class); + when(job.getFullName()).thenReturn("my-org/my-repo"); + when(run.getParent()).thenReturn((Job) job); + when(run.getNumber()).thenReturn(42); + + FixSuggestion suggestion = new FixSuggestion( + true, + "Dependency version mismatch", + "high", + "dependency", + List.of(new FixSuggestion.FileChange("pom.xml", "modify", "--- a/pom.xml\n+++ b/pom.xml\n@@ -1,1 +1,1 @@\n-old\n+new\n", "Update version"))); + + String template = "job={jobName} build=#{buildNumber} conf={confidence} type={fixType}\n{explanation}\n{changesSummary}"; + String body = orchestrator.buildPrBody(run, suggestion, template); + + assertTrue(body.contains("my-org/my-repo"), "jobName placeholder must be substituted"); + assertTrue(body.contains("42"), "buildNumber placeholder must be substituted"); + assertTrue(body.contains("high"), "confidence placeholder must be substituted"); + assertTrue(body.contains("dependency"), "fixType placeholder must be substituted"); + assertTrue(body.contains("Dependency version mismatch"), "explanation placeholder must be substituted"); + assertTrue(body.contains("pom.xml"), "changesSummary must mention file path"); + } + + @Test + @SuppressWarnings("unchecked") + void buildPrBody_noChanges_showsFallback() { + Job job = mock(Job.class); + when(job.getFullName()).thenReturn("proj"); + when(run.getParent()).thenReturn((Job) job); + when(run.getNumber()).thenReturn(1); + + FixSuggestion suggestion = new FixSuggestion(false, null, null, null, null); + String body = orchestrator.buildPrBody(run, suggestion, "{changesSummary}"); + + assertTrue(body.contains("No file changes"), "No changes should show fallback text"); + } + + // ----------------------------------------------------------------------- + // Path 1a — fixable=false → SKIPPED_LOW_CONFIDENCE (AI says not fixable) + // ----------------------------------------------------------------------- + + @Test + void attemptAutoFix_notFixable_returnsSkippedLowConfidence() { + String aiJson = "{\"fixable\": false, \"explanation\": \"Unknown\", \"confidence\": \"low\", \"fixType\": \"unknown\", \"changes\": []}"; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + AutoFixResult result = orchestrator.attemptAutoFix( + run, "some error logs", aiProvider, + "creds-id", null, null, null, null, + Collections.emptyList(), false, 30, listener); + + assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); + // No SCM interactions should have occurred — verified by the fact that + // run.getParent() (which is needed for SCM extraction) was never called + verify(run, never()).getParent(); + } + + // ----------------------------------------------------------------------- + // Path 1b — fixable=true but confidence=low → SKIPPED_LOW_CONFIDENCE + // ----------------------------------------------------------------------- + + @Test + void attemptAutoFix_fixableButLowConfidence_returnsSkippedLowConfidence() { + String aiJson = "{\"fixable\": true, \"explanation\": \"Uncertain\", \"confidence\": \"low\", \"fixType\": \"unknown\", \"changes\": []}"; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "creds-id", null, null, null, null, + Collections.emptyList(), false, 30, listener); + + assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); + verify(run, never()).getParent(); + } + + // ----------------------------------------------------------------------- + // Path 1c — fixable=true, confidence=high, empty changes list + // validateFilePath loop is skipped → proceeds to SCM step, + // which fails with FAILED (no real SCM) rather than NOT_APPLICABLE. + // We verify it did NOT return SKIPPED_LOW_CONFIDENCE or + // SKIPPED_PATH_NOT_ALLOWED — the path-guard is not triggered. + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void attemptAutoFix_emptyChanges_doesNotReturnSkippedStatuses() { + String aiJson = "{\"fixable\": true, \"explanation\": \"Fix available\", \"confidence\": \"high\", \"fixType\": \"config\", \"changes\": []}"; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + // SCM extraction will try run.getParent(); mock it to throw so the test stays fast + Job job = mock(Job.class); + when(run.getParent()).thenReturn((Job) job); + // job.getScm() is called via AbstractProject — using a plain Job mock means + // extractRemoteUrl will hit the "not an AbstractProject" branch and throw, + // causing the future to resolve as FAILED rather than a path-guard status. + + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "creds-id", null, null, null, null, + Collections.emptyList(), false, 30, listener); + + assertNotEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus(), + "Empty changes must not trigger low-confidence skip"); + assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus(), + "Empty changes must not trigger path-not-allowed skip"); + } + + // ----------------------------------------------------------------------- + // Path 8 — allowedPaths excludes the target file → SKIPPED_PATH_NOT_ALLOWED + // ----------------------------------------------------------------------- + + @Test + void attemptAutoFix_pathNotInAllowedList_returnsSkippedPathNotAllowed() { + // allowed: only pom.xml; AI suggests changing src/Main.java + String aiJson = """ + { + "fixable": true, + "explanation": "Code fix", + "confidence": "high", + "fixType": "code", + "changes": [ + { + "filePath": "src/Main.java", + "action": "modify", + "unifiedDiff": "--- a/src/Main.java\\n+++ b/src/Main.java\\n@@ -1,1 +1,1 @@\\n-old\\n+new\\n", + "description": "Fix the bug" + } + ] + } + """; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "creds-id", null, null, null, null, + List.of("pom.xml"), false, 30, listener); + + assertEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus()); + assertTrue(result.getMessage().contains("src/Main.java"), + "Message must name the rejected file path"); + verify(run, never()).getParent(); + } + + // ----------------------------------------------------------------------- + // Path 8 variant — allowed glob matches → path guard passes + // (The test verifies SKIPPED_PATH_NOT_ALLOWED is NOT returned when the + // glob matches; the run proceeds to SCM extraction which fails with FAILED.) + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void attemptAutoFix_pathMatchesAllowedGlob_doesNotReturnPathNotAllowed() { + String aiJson = """ + { + "fixable": true, + "explanation": "Config fix", + "confidence": "high", + "fixType": "config", + "changes": [ + { + "filePath": "pom.xml", + "action": "modify", + "unifiedDiff": "--- a/pom.xml\\n+++ b/pom.xml\\n@@ -1,1 +1,1 @@\\n-old\\n+new\\n", + "description": "Update version" + } + ] + } + """; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + Job job = mock(Job.class); + when(run.getParent()).thenReturn((Job) job); + + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "creds-id", null, null, null, null, + List.of("pom.xml"), false, 30, listener); + + assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus(), + "pom.xml matches the allowed glob and must not be rejected"); + } +} diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/GitLabApiClientTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/GitLabApiClientTest.java new file mode 100644 index 00000000..0ff16857 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/GitLabApiClientTest.java @@ -0,0 +1,269 @@ +package io.jenkins.plugins.explain_error.autofix; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.jenkins.plugins.explain_error.autofix.scm.GitLabApiClient; +import io.jenkins.plugins.explain_error.autofix.scm.PullRequest; +import io.jenkins.plugins.explain_error.autofix.scm.ScmRepo; +import io.jenkins.plugins.explain_error.autofix.scm.ScmType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +class GitLabApiClientTest { + + private WireMockServer server; + private GitLabApiClient client; + + // The URL-encoded project path "owner/repo" → "owner%2Frepo" + private static final String ENCODED_PATH = "owner%2Frepo"; + // Numeric project ID returned by the project-lookup stub + private static final String PROJECT_ID = "999"; + + @BeforeEach + void setUp() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + configureFor("localhost", server.port()); + + ScmRepo repo = new ScmRepo( + ScmType.GITLAB, + "http://localhost:" + server.port() + "/api/v4", + "owner", + "repo", + "test-token"); + client = new GitLabApiClient(repo); + } + + @AfterEach + void tearDown() { + server.stop(); + } + + // ----------------------------------------------------------------------- + // Shared helper: stub the project-path lookup that returns the numeric ID. + // Many operations call projectIdUrl() which first fetches the project by + // namespace path, caches the ID, then uses /projects/:id for mutations. + // ----------------------------------------------------------------------- + + private void stubProjectLookup() { + stubFor(get(urlEqualTo("/api/v4/projects/" + ENCODED_PATH)) + .willReturn(okJson( + "{\"id\": " + PROJECT_ID + ", \"default_branch\": \"main\"}"))); + } + + // ----------------------------------------------------------------------- + // getDefaultBranch + // ----------------------------------------------------------------------- + + @Test + void getDefaultBranch_success() throws Exception { + stubFor(get(urlEqualTo("/api/v4/projects/" + ENCODED_PATH)) + .willReturn(okJson( + "{\"id\": " + PROJECT_ID + ", \"default_branch\": \"develop\"}"))); + + assertEquals("develop", client.getDefaultBranch()); + } + + // ----------------------------------------------------------------------- + // validateWriteAccess + // ----------------------------------------------------------------------- + + @Test + void validateWriteAccess_developerAccess_succeeds() { + // project_access.access_level >= 30 (Developer) + stubFor(get(urlEqualTo("/api/v4/projects/" + ENCODED_PATH + "?simple=true")) + .willReturn(okJson( + "{\"id\": " + PROJECT_ID + ", \"permissions\": {\"project_access\": {\"access_level\": 30}, \"group_access\": {\"access_level\": 0}}}"))); + + assertDoesNotThrow(() -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_reporterAccess_throws() { + // project_access.access_level < 30 (Reporter) and group_access also low + stubFor(get(urlEqualTo("/api/v4/projects/" + ENCODED_PATH + "?simple=true")) + .willReturn(okJson( + "{\"id\": " + PROJECT_ID + ", \"permissions\": {\"project_access\": {\"access_level\": 20}, \"group_access\": {\"access_level\": 0}}}"))); + + assertThrows(Exception.class, () -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_groupDeveloperAccess_succeeds() { + // project_access is low but group_access.access_level >= 30 is sufficient + stubFor(get(urlEqualTo("/api/v4/projects/" + ENCODED_PATH + "?simple=true")) + .willReturn(okJson( + "{\"id\": " + PROJECT_ID + ", \"permissions\": {\"project_access\": {\"access_level\": 0}, \"group_access\": {\"access_level\": 40}}}"))); + + assertDoesNotThrow(() -> client.validateWriteAccess()); + } + + // ----------------------------------------------------------------------- + // createBranch + // ----------------------------------------------------------------------- + + @Test + void createBranch_success() throws Exception { + stubProjectLookup(); + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/branches")) + .willReturn(aResponse().withStatus(201).withBody("{\"name\": \"fix/test\"}"))); + + assertDoesNotThrow(() -> client.createBranch("fix/test", "main")); + } + + @Test + void createBranch_collision_retriesWithSuffix() throws Exception { + stubProjectLookup(); + + // First POST returns 400 (branch exists); second succeeds on a suffixed name + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/branches")) + .inScenario("branchCollision") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(400).withBody("{\"message\": \"Branch already exists\"}")) + .willSetStateTo("retry")); + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/branches")) + .inScenario("branchCollision") + .whenScenarioStateIs("retry") + .willReturn(aResponse().withStatus(201).withBody("{\"name\": \"fix/test-ab12\"}"))); + + assertDoesNotThrow(() -> client.createBranch("fix/test", "main")); + } + + // ----------------------------------------------------------------------- + // commitFiles (atomic via GitLab Commits API) + // ----------------------------------------------------------------------- + + @Test + void commitFiles_atomic_success() throws Exception { + stubProjectLookup(); + + // getFileContent is called per file to decide "update" vs "create" action + String encoded = Base64.getEncoder().encodeToString("old content\n".getBytes()); + stubFor(get(urlPathEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/files/pom.xml")) + .willReturn(okJson("{\"content\": \"" + encoded + "\"}"))); + + // POST to the commits endpoint + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/commits")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"id\": \"abc123def456\", \"short_id\": \"abc123\"}"))); + + assertDoesNotThrow(() -> + client.commitFiles("fix-branch", "fix: apply patch", Map.of("pom.xml", ""))); + } + + @Test + void commitFiles_newFile_usesCreateAction() throws Exception { + stubProjectLookup(); + + // File does not exist on the branch → getFileContent returns 404 + stubFor(get(urlPathEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/files/new-file.txt")) + .willReturn(aResponse().withStatus(404).withBody("{\"message\": \"404 File Not Found\"}"))); + + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/commits")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"id\": \"deadbeef\", \"short_id\": \"deadbeef\"}"))); + + assertDoesNotThrow(() -> + client.commitFiles("fix-branch", "feat: add new file", Map.of("new-file.txt", "hello\n"))); + } + + // ----------------------------------------------------------------------- + // createPullRequest (Merge Request) + // ----------------------------------------------------------------------- + + @Test + void createPullRequest_success() throws Exception { + stubProjectLookup(); + + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/merge_requests")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"iid\": 7, \"web_url\": \"https://gitlab.com/owner/repo/-/merge_requests/7\"}"))); + + PullRequest pr = client.createPullRequest("fix: auto-fix", "body text", "fix-branch", "main", false); + assertEquals(7, pr.number()); + assertEquals("https://gitlab.com/owner/repo/-/merge_requests/7", pr.url()); + } + + @Test + void createPullRequest_draft() throws Exception { + stubProjectLookup(); + + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/merge_requests")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"iid\": 12, \"web_url\": \"https://gitlab.com/owner/repo/-/merge_requests/12\"}"))); + + PullRequest pr = client.createPullRequest("Draft: fix", "body", "fix-branch", "main", true); + assertEquals(12, pr.number()); + } + + @Test + void createPullRequest_serverError_throws() { + stubProjectLookup(); + + stubFor(post(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/merge_requests")) + .willReturn(aResponse().withStatus(500).withBody("{\"message\": \"Internal Server Error\"}"))); + + assertThrows(Exception.class, () -> + client.createPullRequest("fix: test", "body", "fix-branch", "main", false)); + } + + // ----------------------------------------------------------------------- + // deleteBranch + // ----------------------------------------------------------------------- + + @Test + void deleteBranch_success() throws Exception { + stubProjectLookup(); + stubFor(delete(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/branches/fix-branch")) + .willReturn(aResponse().withStatus(204))); + + assertDoesNotThrow(() -> client.deleteBranch("fix-branch")); + } + + @Test + void deleteBranch_alreadyGone_throws() { + stubProjectLookup(); + // GitLabApiClient treats non-200/204 as an error (unlike GitHub client's 422 leniency) + stubFor(delete(urlEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/branches/fix-branch")) + .willReturn(aResponse().withStatus(404).withBody("{\"message\": \"404 Branch Not Found\"}"))); + + assertThrows(Exception.class, () -> client.deleteBranch("fix-branch")); + } + + // ----------------------------------------------------------------------- + // getFileContent + // ----------------------------------------------------------------------- + + @Test + void getFileContent_found() throws Exception { + stubProjectLookup(); + + String encoded = Base64.getEncoder().encodeToString("hello gitlab\n".getBytes()); + stubFor(get(urlPathEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/files/pom.xml")) + .willReturn(okJson("{\"content\": \"" + encoded + "\"}"))); + + String content = client.getFileContent("pom.xml", "main"); + assertEquals("hello gitlab\n", content); + } + + @Test + void getFileContent_notFound_returnsNull() throws Exception { + stubProjectLookup(); + + stubFor(get(urlPathEqualTo("/api/v4/projects/" + PROJECT_ID + "/repository/files/missing.txt")) + .willReturn(aResponse().withStatus(404).withBody("{\"message\": \"404 File Not Found\"}"))); + + assertNull(client.getFileContent("missing.txt", "main")); + } +} From 0a1784fc8957a521b5e3acc08b0948779bfb9ec3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 09:39:06 +0200 Subject: [PATCH 04/14] fix: implement scmTypeOverride to support self-hosted SCM instances When autoFixScmType is specified (github/gitlab/bitbucket), bypass URL-based host detection and construct ScmRepo directly with the forced type and enterprise base URL. This fixes the case where users have self-hosted instances (e.g. git.company.com) whose hostname is not recognised by auto-detection, causing an IllegalArgumentException. - Add ScmRepo.parseWithOverride() factory method - buildScmRepo() now checks scmTypeOverride first; auto-detection is used only as a fallback when no override is given Co-Authored-By: Claude Sonnet 4.6 --- .../autofix/AutoFixOrchestrator.java | 49 ++++++++++++++++--- .../explain_error/autofix/scm/ScmRepo.java | 43 ++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index 64a2ee91..b581e734 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -421,12 +421,53 @@ String extractRemoteUrl(Run run) { /** * Builds a {@link ScmRepo} from the remote URL and applies any enterprise URL overrides. + * + *

When {@code scmTypeOverride} is specified, it bypasses URL-based host detection — + * required for self-hosted instances (e.g. {@code git.company.com}) whose hostname + * does not match {@code github.com}, {@code gitlab.com}, or {@code bitbucket.org}. */ private ScmRepo buildScmRepo(String remoteUrl, String token, String scmTypeOverride, String githubEnterpriseUrl, String gitlabUrl, String bitbucketUrl) { + // If scmTypeOverride is provided, use it directly so self-hosted instances work. + if (scmTypeOverride != null && !scmTypeOverride.isBlank()) { + ScmType forcedType; + switch (scmTypeOverride.trim().toLowerCase()) { + case "github": forcedType = ScmType.GITHUB; break; + case "gitlab": forcedType = ScmType.GITLAB; break; + case "bitbucket": forcedType = ScmType.BITBUCKET; break; + default: + LOGGER.warning("Unrecognised scmTypeOverride '" + scmTypeOverride + + "' — falling back to URL-based detection"); + forcedType = null; + } + if (forcedType != null) { + String baseUrl; + switch (forcedType) { + case GITHUB: + baseUrl = (githubEnterpriseUrl != null && !githubEnterpriseUrl.isBlank()) + ? githubEnterpriseUrl.stripTrailing() + "/api/v3" + : "https://api.github.com"; + break; + case GITLAB: + baseUrl = (gitlabUrl != null && !gitlabUrl.isBlank()) + ? gitlabUrl.stripTrailing() + "/api/v4" + : "https://gitlab.com/api/v4"; + break; + case BITBUCKET: + baseUrl = (bitbucketUrl != null && !bitbucketUrl.isBlank()) + ? bitbucketUrl.stripTrailing() + "/2.0" + : "https://api.bitbucket.org/2.0"; + break; + default: + baseUrl = null; // unreachable + } + return ScmRepo.parseWithOverride(remoteUrl, token, forcedType, baseUrl); + } + } + ScmRepo repo = ScmRepo.parse(remoteUrl, token); - // Apply enterprise base-URL overrides + // Apply enterprise base-URL overrides when the host was auto-detected if (repo.scmType() == ScmType.GITHUB && githubEnterpriseUrl != null && !githubEnterpriseUrl.isBlank()) { repo = repo.withBaseUrl(githubEnterpriseUrl.stripTrailing() + "/api/v3"); } else if (repo.scmType() == ScmType.GITLAB && gitlabUrl != null && !gitlabUrl.isBlank()) { @@ -435,12 +476,6 @@ private ScmRepo buildScmRepo(String remoteUrl, String token, String scmTypeOverr repo = repo.withBaseUrl(bitbucketUrl.stripTrailing() + "/2.0"); } - // scmTypeOverride is informational / for future use; ScmRepo.parse already detected the type - // from the URL. If needed, a caller could re-parse with a different host mapping. - if (scmTypeOverride != null && !scmTypeOverride.isBlank()) { - LOGGER.fine("scmTypeOverride='" + scmTypeOverride + "' specified; current detection: " + repo.scmType()); - } - return repo; } diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java index 5198e5f4..00161753 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/scm/ScmRepo.java @@ -66,6 +66,39 @@ public static ScmRepo parse(String remoteUrl, String token) { return new ScmRepo(scmType, baseUrl, owner, repoName, token); } + /** + * Parses owner/repoName from a remote URL and constructs a ScmRepo with an explicit + * ScmType and baseUrl. Used when the hostname is not a known public service (self-hosted + * instances) and the caller already knows the SCM type via {@code scmTypeOverride}. + * + * @param remoteUrl the remote URL (SSH or HTTPS format) — used to extract owner/repoName + * @param token the authentication token (plaintext) + * @param scmType the SCM type to use (bypasses host-based auto-detection) + * @param baseUrl the API base URL to use + * @return a populated ScmRepo with the overridden type and baseUrl + * @throws IllegalArgumentException if the URL cannot be parsed + */ + public static ScmRepo parseWithOverride(String remoteUrl, String token, ScmType scmType, String baseUrl) { + if (remoteUrl == null || remoteUrl.isBlank()) { + throw new IllegalArgumentException("Remote URL must not be null or blank"); + } + String url = remoteUrl.trim(); + Matcher sshMatcher = SSH_PATTERN.matcher(url); + Matcher httpsMatcher = HTTPS_PATTERN.matcher(url); + String owner; + String repoName; + if (sshMatcher.matches()) { + owner = sshMatcher.group(2); + repoName = sshMatcher.group(3); + } else if (httpsMatcher.matches()) { + owner = httpsMatcher.group(2); + repoName = httpsMatcher.group(3); + } else { + throw new IllegalArgumentException("Cannot parse owner/repo from remote URL: " + remoteUrl); + } + return new ScmRepo(scmType, baseUrl, owner, repoName, token); + } + /** * Returns a new ScmRepo with the baseUrl overridden (for enterprise instances). * @@ -75,4 +108,14 @@ public static ScmRepo parse(String remoteUrl, String token) { public ScmRepo withBaseUrl(String baseUrl) { return new ScmRepo(this.scmType, baseUrl, this.owner, this.repoName, this.token); } + + /** + * Overrides the record-generated toString() to redact the token so it is never + * accidentally printed in build logs or exception stack traces. + */ + @Override + public String toString() { + return "ScmRepo[scmType=" + scmType + ", baseUrl=" + baseUrl + + ", owner=" + owner + ", repoName=" + repoName + ", token=[REDACTED]]"; + } } From c1c84261301f685800c6521b2c8edc627a85bcf7 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 09:42:18 +0200 Subject: [PATCH 05/14] fix: guard against fixable=true with empty changes list When AI returns fixable=true but an empty changes array, exit early with SKIPPED_LOW_CONFIDENCE rather than proceeding to create an empty branch and PR. Prevents orphaned remote branches and empty PRs. Update path-1c test to assert the early-return behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../autofix/AutoFixOrchestrator.java | 5 +++++ .../autofix/AutoFixOrchestratorTest.java | 22 +++++-------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index b581e734..8e8d6e35 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -184,6 +184,11 @@ private AutoFixResult doAttemptAutoFix( return AutoFixResult.skippedLowConfidence(); } + if (suggestion.changes() == null || suggestion.changes().isEmpty()) { + listener.getLogger().println("[AutoFix] Skipping: AI returned fixable=true but no file changes."); + return AutoFixResult.skippedLowConfidence(); + } + // Step 3 — Validate file paths if (suggestion.changes() != null) { for (FixSuggestion.FileChange change : suggestion.changes()) { diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java index 7921e056..c9b0b3c7 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java @@ -208,34 +208,22 @@ void attemptAutoFix_fixableButLowConfidence_returnsSkippedLowConfidence() { // ----------------------------------------------------------------------- // Path 1c — fixable=true, confidence=high, empty changes list - // validateFilePath loop is skipped → proceeds to SCM step, - // which fails with FAILED (no real SCM) rather than NOT_APPLICABLE. - // We verify it did NOT return SKIPPED_LOW_CONFIDENCE or - // SKIPPED_PATH_NOT_ALLOWED — the path-guard is not triggered. + // Guard added: empty changes → SKIPPED_LOW_CONFIDENCE before any + // SCM or branch operations are attempted. // ----------------------------------------------------------------------- @Test - @SuppressWarnings("unchecked") - void attemptAutoFix_emptyChanges_doesNotReturnSkippedStatuses() { + void attemptAutoFix_emptyChanges_returnsSkipped() { String aiJson = "{\"fixable\": true, \"explanation\": \"Fix available\", \"confidence\": \"high\", \"fixType\": \"config\", \"changes\": []}"; when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); - // SCM extraction will try run.getParent(); mock it to throw so the test stays fast - Job job = mock(Job.class); - when(run.getParent()).thenReturn((Job) job); - // job.getScm() is called via AbstractProject — using a plain Job mock means - // extractRemoteUrl will hit the "not an AbstractProject" branch and throw, - // causing the future to resolve as FAILED rather than a path-guard status. - AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, "creds-id", null, null, null, null, Collections.emptyList(), false, 30, listener); - assertNotEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus(), - "Empty changes must not trigger low-confidence skip"); - assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus(), - "Empty changes must not trigger path-not-allowed skip"); + assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus(), + "Empty changes list must be treated as skipped (no changes to commit)"); } // ----------------------------------------------------------------------- From 4e027fb7ccb9f94bf6fee00486c759f314165f5f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 09:45:50 +0200 Subject: [PATCH 06/14] fix: single-pass template substitution in buildPrBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace chained .replace() calls with a single TEMPLATE_PLACEHOLDER regex pass using replaceAll(Function). AI-provided values (explanation, descriptions) are now substituted literally — they cannot accidentally match and corrupt subsequent placeholder tokens. Co-Authored-By: Claude Sonnet 4.6 --- .../autofix/AutoFixOrchestrator.java | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index 8e8d6e35..fb235f27 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -39,6 +40,9 @@ public class AutoFixOrchestrator { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** Matches {@code {word}} placeholder tokens in PR body templates. */ + private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("\\{(\\w+)\\}"); + /** Default PR body template with {@code {variable}} placeholders. */ private static final String DEFAULT_PR_TEMPLATE = """ ## AI Auto-Fix for {jobName} #{buildNumber} @@ -539,13 +543,21 @@ String buildPrBody(Run run, FixSuggestion suggestion, String template) { changesSummary.append("No file changes.\n"); } - return template - .replace("{jobName}", jobName) - .replace("{buildNumber}", buildNumber) - .replace("{explanation}", explanation) - .replace("{changesSummary}", changesSummary.toString().stripTrailing()) - .replace("{fixType}", fixType) - .replace("{confidence}", confidence); + // Build values map and substitute all placeholders in a single pass so that + // AI-provided content (explanation, descriptions) cannot match and corrupt + // subsequent placeholder tokens (e.g. if explanation itself contains "{fixType}"). + Map values = Map.of( + "jobName", jobName, + "buildNumber", buildNumber, + "explanation", explanation, + "changesSummary", changesSummary.toString().stripTrailing(), + "fixType", fixType, + "confidence", confidence + ); + return TEMPLATE_PLACEHOLDER.matcher(template).replaceAll(m -> { + String val = values.get(m.group(1)); + return val != null ? val : m.group(0); + }); } /** From 97c5ceafbd094218384dfeedb8adf746fd89b9c2 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 09:48:49 +0200 Subject: [PATCH 07/14] feat: add autoFixPrTemplate pipeline parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread prTemplate through attemptAutoFix → doAttemptAutoFix. When the parameter is set, it overrides the built-in default PR body template, allowing users to embed custom reviewer requirements, checklists, or company-specific content in auto-fix PRs. When null/blank, the default template is used. Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/explain_error/ExplainErrorStep.java | 13 ++++++++++++- .../explain_error/autofix/AutoFixOrchestrator.java | 11 +++++++---- .../autofix/AutoFixOrchestratorTest.java | 10 +++++----- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java index ef7935a7..8108b0af 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -41,6 +41,7 @@ public class ExplainErrorStep extends Step { private String autoFixAllowedPaths = "pom.xml,build.gradle,build.gradle.kts,*.properties,*.yml,*.yaml,Jenkinsfile,Dockerfile,package.json,requirements.txt,go.mod"; private boolean autoFixDraftPr = false; private int autoFixTimeoutSeconds = 60; + private String autoFixPrTemplate = ""; @DataBoundConstructor public ExplainErrorStep() { @@ -188,6 +189,15 @@ public void setAutoFixTimeoutSeconds(int autoFixTimeoutSeconds) { this.autoFixTimeoutSeconds = autoFixTimeoutSeconds > 0 ? autoFixTimeoutSeconds : 60; } + public String getAutoFixPrTemplate() { + return autoFixPrTemplate; + } + + @DataBoundSetter + public void setAutoFixPrTemplate(String autoFixPrTemplate) { + this.autoFixPrTemplate = autoFixPrTemplate != null ? autoFixPrTemplate : ""; + } + @Override public StepExecution start(StepContext context) throws Exception { return new ExplainErrorStepExecution(context, this); @@ -252,7 +262,8 @@ protected String run() throws Exception { allowedPaths, step.isAutoFixDraftPr(), step.getAutoFixTimeoutSeconds(), - listener); + listener, + step.getAutoFixPrTemplate().isEmpty() ? null : step.getAutoFixPrTemplate()); listener.getLogger().println("[AutoFix] Status: " + fixResult.getStatus() + " - " + fixResult.getMessage()); if (fixResult.getStatus() == AutoFixStatus.CREATED) { listener.getLogger().println("[AutoFix] PR created: " + fixResult.getPrUrl()); diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index fb235f27..791c66ef 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -102,7 +102,8 @@ public AutoFixResult attemptAutoFix( List allowedPathGlobs, boolean draftPr, int timeoutSeconds, - TaskListener listener) { + TaskListener listener, + String prTemplate) { // We keep track of the branch name so we can clean up on timeout/failure. final String[] createdBranchRef = {null}; @@ -112,7 +113,7 @@ public AutoFixResult attemptAutoFix( return doAttemptAutoFix( run, errorLogs, aiProvider, credentialsId, scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl, - allowedPathGlobs, draftPr, listener, createdBranchRef); + allowedPathGlobs, draftPr, listener, createdBranchRef, prTemplate); } catch (Exception e) { LOGGER.log(Level.WARNING, "Auto-fix failed with exception", e); listener.getLogger().println("[AutoFix] Error: " + e.getMessage()); @@ -168,7 +169,8 @@ private AutoFixResult doAttemptAutoFix( List allowedPathGlobs, boolean draftPr, TaskListener listener, - String[] createdBranchRef) throws Exception { + String[] createdBranchRef, + String prTemplate) throws Exception { listener.getLogger().println("[AutoFix] Requesting fix suggestion from AI provider..."); @@ -298,7 +300,8 @@ private AutoFixResult doAttemptAutoFix( // Step 10 — Create PR String prTitle = "fix: AI auto-fix for " + run.getParent().getFullName() + " #" + run.getNumber(); - String prBody = buildPrBody(run, suggestion, DEFAULT_PR_TEMPLATE); + String effectiveTemplate = (prTemplate != null && !prTemplate.isBlank()) ? prTemplate : DEFAULT_PR_TEMPLATE; + String prBody = buildPrBody(run, suggestion, effectiveTemplate); PullRequest pr; try { diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java index c9b0b3c7..8c5cb1ba 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java @@ -180,7 +180,7 @@ void attemptAutoFix_notFixable_returnsSkippedLowConfidence() { AutoFixResult result = orchestrator.attemptAutoFix( run, "some error logs", aiProvider, "creds-id", null, null, null, null, - Collections.emptyList(), false, 30, listener); + Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); // No SCM interactions should have occurred — verified by the fact that @@ -200,7 +200,7 @@ void attemptAutoFix_fixableButLowConfidence_returnsSkippedLowConfidence() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, "creds-id", null, null, null, null, - Collections.emptyList(), false, 30, listener); + Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); verify(run, never()).getParent(); @@ -220,7 +220,7 @@ void attemptAutoFix_emptyChanges_returnsSkipped() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, "creds-id", null, null, null, null, - Collections.emptyList(), false, 30, listener); + Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus(), "Empty changes list must be treated as skipped (no changes to commit)"); @@ -254,7 +254,7 @@ void attemptAutoFix_pathNotInAllowedList_returnsSkippedPathNotAllowed() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, "creds-id", null, null, null, null, - List.of("pom.xml"), false, 30, listener); + List.of("pom.xml"), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus()); assertTrue(result.getMessage().contains("src/Main.java"), @@ -295,7 +295,7 @@ void attemptAutoFix_pathMatchesAllowedGlob_doesNotReturnPathNotAllowed() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, "creds-id", null, null, null, null, - List.of("pom.xml"), false, 30, listener); + List.of("pom.xml"), false, 30, listener, null); assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus(), "pom.xml matches the allowed glob and must not be rejected"); From d2366a04cbf1240074e6ecff2f8f2a0d6d3a9286 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 10:03:21 +0200 Subject: [PATCH 08/14] test: add regression test for PR body placeholder safety and ScmRepo - buildPrBody: regression test verifying AI output containing {fixType} is not double-substituted (covers Issue 4 fix) - ScmRepoTest: 13 tests covering parse() for GitHub/GitLab/Bitbucket, parseWithOverride() for SSH/HTTPS/malformed URLs, withBaseUrl(), and toString() token redaction Co-Authored-By: Claude Sonnet 4.6 --- .../autofix/AutoFixOrchestratorTest.java | 23 ++++ .../explain_error/autofix/ScmRepoTest.java | 129 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/ScmRepoTest.java diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java index 8c5cb1ba..9a1cc05c 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java @@ -154,6 +154,29 @@ void buildPrBody_substitutesAllPlaceholders() { assertTrue(body.contains("pom.xml"), "changesSummary must mention file path"); } + @Test + @SuppressWarnings("unchecked") + void buildPrBody_aiContentContainingPlaceholder_isNotDoubleSubstituted() { + // Regression: Issue 4 — multi-pass .replace() could corrupt AI output + // if the explanation itself contained a {placeholder} token. + Job job = mock(Job.class); + when(job.getFullName()).thenReturn("proj"); + when(run.getParent()).thenReturn((Job) job); + when(run.getNumber()).thenReturn(7); + + // explanation contains "{fixType}" — should appear literally in the output + FixSuggestion suggestion = new FixSuggestion( + true, "Use {fixType} carefully", "high", "dependency", null); + + String body = orchestrator.buildPrBody(run, suggestion, + "explanation={explanation} type={fixType}"); + + assertTrue(body.contains("Use {fixType} carefully"), + "AI output containing {fixType} must not be substituted a second time"); + assertTrue(body.contains("type=dependency"), + "The actual {fixType} placeholder in the template must still be substituted"); + } + @Test @SuppressWarnings("unchecked") void buildPrBody_noChanges_showsFallback() { diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/ScmRepoTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/ScmRepoTest.java new file mode 100644 index 00000000..8d842827 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/ScmRepoTest.java @@ -0,0 +1,129 @@ +package io.jenkins.plugins.explain_error.autofix; + +import io.jenkins.plugins.explain_error.autofix.scm.ScmRepo; +import io.jenkins.plugins.explain_error.autofix.scm.ScmType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ScmRepoTest { + + // ----------------------------------------------------------------------- + // ScmRepo.parse() — happy paths + // ----------------------------------------------------------------------- + + @Test + void parse_githubSsh_detectsGitHub() { + ScmRepo repo = ScmRepo.parse("git@github.com:acme/my-repo.git", "tok"); + assertEquals(ScmType.GITHUB, repo.scmType()); + assertEquals("https://api.github.com", repo.baseUrl()); + assertEquals("acme", repo.owner()); + assertEquals("my-repo", repo.repoName()); + } + + @Test + void parse_githubHttps_detectsGitHub() { + ScmRepo repo = ScmRepo.parse("https://github.com/acme/my-repo.git", "tok"); + assertEquals(ScmType.GITHUB, repo.scmType()); + assertEquals("acme", repo.owner()); + assertEquals("my-repo", repo.repoName()); + } + + @Test + void parse_gitlabSsh_detectsGitLab() { + ScmRepo repo = ScmRepo.parse("git@gitlab.com:acme/my-repo.git", "tok"); + assertEquals(ScmType.GITLAB, repo.scmType()); + assertEquals("https://gitlab.com/api/v4", repo.baseUrl()); + } + + @Test + void parse_bitbucketHttps_detectsBitbucket() { + ScmRepo repo = ScmRepo.parse("https://bitbucket.org/acme/my-repo.git", "tok"); + assertEquals(ScmType.BITBUCKET, repo.scmType()); + assertEquals("https://api.bitbucket.org/2.0", repo.baseUrl()); + } + + @Test + void parse_unknownHost_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> ScmRepo.parse("git@git.company.com:acme/my-repo.git", "tok")); + } + + @Test + void parse_nullUrl_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> ScmRepo.parse(null, "tok")); + } + + @Test + void parse_blankUrl_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> ScmRepo.parse(" ", "tok")); + } + + // ----------------------------------------------------------------------- + // ScmRepo.parseWithOverride() — happy paths + // ----------------------------------------------------------------------- + + @Test + void parseWithOverride_sshUrl_extractsOwnerAndRepo() { + ScmRepo repo = ScmRepo.parseWithOverride( + "git@git.company.com:acme/my-repo.git", "tok", + ScmType.GITLAB, "https://git.company.com/api/v4"); + assertEquals(ScmType.GITLAB, repo.scmType()); + assertEquals("https://git.company.com/api/v4", repo.baseUrl()); + assertEquals("acme", repo.owner()); + assertEquals("my-repo", repo.repoName()); + } + + @Test + void parseWithOverride_httpsUrl_extractsOwnerAndRepo() { + ScmRepo repo = ScmRepo.parseWithOverride( + "https://github.enterprise.acme.com/acme/my-repo.git", "tok", + ScmType.GITHUB, "https://github.enterprise.acme.com/api/v3"); + assertEquals(ScmType.GITHUB, repo.scmType()); + assertEquals("acme", repo.owner()); + assertEquals("my-repo", repo.repoName()); + } + + @Test + void parseWithOverride_malformedUrl_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> ScmRepo.parseWithOverride("not-a-url", "tok", + ScmType.GITHUB, "https://api.github.com")); + } + + @Test + void parseWithOverride_nullUrl_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> ScmRepo.parseWithOverride(null, "tok", + ScmType.GITLAB, "https://gitlab.com/api/v4")); + } + + // ----------------------------------------------------------------------- + // ScmRepo.withBaseUrl() + // ----------------------------------------------------------------------- + + @Test + void withBaseUrl_returnsNewRepoWithUpdatedBaseUrl() { + ScmRepo repo = ScmRepo.parse("git@github.com:acme/my-repo.git", "tok"); + ScmRepo updated = repo.withBaseUrl("https://github.enterprise.acme.com/api/v3"); + assertEquals("https://github.enterprise.acme.com/api/v3", updated.baseUrl()); + // All other fields unchanged + assertEquals(repo.scmType(), updated.scmType()); + assertEquals(repo.owner(), updated.owner()); + assertEquals(repo.repoName(), updated.repoName()); + } + + // ----------------------------------------------------------------------- + // ScmRepo.toString() — token must be redacted + // ----------------------------------------------------------------------- + + @Test + void toString_redactsToken() { + ScmRepo repo = ScmRepo.parse("git@github.com:acme/my-repo.git", "super-secret-token"); + String str = repo.toString(); + assertFalse(str.contains("super-secret-token"), "Token must not appear in toString()"); + assertTrue(str.contains("[REDACTED]"), "toString() must contain [REDACTED]"); + } +} From fab633b52398641f73f9c780a9221fd2cf808966 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 10:05:17 +0200 Subject: [PATCH 09/14] test: add BitbucketApiClientTest with 15 WireMock integration tests Covers: getDefaultBranch, validateWriteAccess (write/admin/read-only/401), createBranch (success/422), getFileContent (200/404), commitFiles (201/400), createPullRequest (success/422), deleteBranch (204/404). Co-Authored-By: Claude Sonnet 4.6 --- .../autofix/BitbucketApiClientTest.java | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 src/test/java/io/jenkins/plugins/explain_error/autofix/BitbucketApiClientTest.java diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/BitbucketApiClientTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/BitbucketApiClientTest.java new file mode 100644 index 00000000..b2929eed --- /dev/null +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/BitbucketApiClientTest.java @@ -0,0 +1,209 @@ +package io.jenkins.plugins.explain_error.autofix; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.jenkins.plugins.explain_error.autofix.scm.BitbucketApiClient; +import io.jenkins.plugins.explain_error.autofix.scm.PullRequest; +import io.jenkins.plugins.explain_error.autofix.scm.ScmRepo; +import io.jenkins.plugins.explain_error.autofix.scm.ScmType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.*; + +class BitbucketApiClientTest { + + private WireMockServer server; + private BitbucketApiClient client; + + private static final String REPO_PATH = "/2.0/repositories/owner/repo"; + + @BeforeEach + void setUp() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + configureFor("localhost", server.port()); + + ScmRepo repo = new ScmRepo( + ScmType.BITBUCKET, + "http://localhost:" + server.port() + "/2.0", + "owner", + "repo", + "test-token"); + client = new BitbucketApiClient(repo); + } + + @AfterEach + void tearDown() { + server.stop(); + } + + // ----------------------------------------------------------------------- + // getDefaultBranch + // ----------------------------------------------------------------------- + + @Test + void getDefaultBranch_returnsMainBranchName() throws Exception { + stubFor(get(urlEqualTo(REPO_PATH)) + .willReturn(okJson("{\"mainbranch\":{\"name\":\"main\"}}"))); + + assertEquals("main", client.getDefaultBranch()); + } + + // ----------------------------------------------------------------------- + // validateWriteAccess + // ----------------------------------------------------------------------- + + @Test + void validateWriteAccess_writePermission_succeeds() { + stubFor(get(urlEqualTo(REPO_PATH)) + .willReturn(okJson("{\"full_name\":\"owner/repo\"}"))); + stubFor(get(urlPathEqualTo("/2.0/user/permissions/repositories")) + .willReturn(okJson("{\"values\":[{\"permission\":\"write\",\"repository\":{\"full_name\":\"owner/repo\"}}]}"))); + + assertDoesNotThrow(() -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_readOnlyPermission_throwsIOException() { + stubFor(get(urlEqualTo(REPO_PATH)) + .willReturn(okJson("{\"full_name\":\"owner/repo\"}"))); + stubFor(get(urlPathEqualTo("/2.0/user/permissions/repositories")) + .willReturn(okJson("{\"values\":[{\"permission\":\"read\",\"repository\":{\"full_name\":\"owner/repo\"}}]}"))); + + assertThrows(Exception.class, () -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_repoGet401_throwsIOException() { + stubFor(get(urlEqualTo(REPO_PATH)) + .willReturn(aResponse().withStatus(401).withBody("{\"error\":{\"message\":\"Unauthorized\"}}"))); + + assertThrows(Exception.class, () -> client.validateWriteAccess()); + } + + @Test + void validateWriteAccess_adminPermission_succeeds() { + stubFor(get(urlEqualTo(REPO_PATH)) + .willReturn(okJson("{\"full_name\":\"owner/repo\"}"))); + stubFor(get(urlPathEqualTo("/2.0/user/permissions/repositories")) + .willReturn(okJson("{\"values\":[{\"permission\":\"admin\",\"repository\":{\"full_name\":\"owner/repo\"}}]}"))); + + assertDoesNotThrow(() -> client.validateWriteAccess()); + } + + // ----------------------------------------------------------------------- + // createBranch + // ----------------------------------------------------------------------- + + @Test + void createBranch_success() { + stubFor(get(urlEqualTo(REPO_PATH + "/refs/branches/main")) + .willReturn(okJson("{\"name\":\"main\",\"target\":{\"hash\":\"abc123def456\"}}"))); + stubFor(post(urlEqualTo(REPO_PATH + "/refs/branches")) + .willReturn(aResponse().withStatus(201).withBody("{\"name\":\"fix/test\"}"))); + + assertDoesNotThrow(() -> client.createBranch("fix/test", "main")); + } + + @Test + void createBranch_postReturns422_throwsIOException() { + stubFor(get(urlEqualTo(REPO_PATH + "/refs/branches/main")) + .willReturn(okJson("{\"name\":\"main\",\"target\":{\"hash\":\"abc123def456\"}}"))); + stubFor(post(urlEqualTo(REPO_PATH + "/refs/branches")) + .willReturn(aResponse().withStatus(422).withBody("{\"error\":{\"message\":\"Branch already exists\"}}"))); + + assertThrows(Exception.class, () -> client.createBranch("fix/test", "main")); + } + + // ----------------------------------------------------------------------- + // getFileContent + // ----------------------------------------------------------------------- + + @Test + void getFileContent_found_returnsContent() throws Exception { + stubFor(get(urlEqualTo(REPO_PATH + "/src/main/pom.xml")) + .willReturn(aResponse().withStatus(200).withBody("content"))); + + assertEquals("content", client.getFileContent("pom.xml", "main")); + } + + @Test + void getFileContent_notFound_returnsNull() throws Exception { + stubFor(get(urlEqualTo(REPO_PATH + "/src/main/missing.txt")) + .willReturn(aResponse().withStatus(404).withBody("{\"error\":{\"message\":\"Not Found\"}}"))); + + assertNull(client.getFileContent("missing.txt", "main")); + } + + // ----------------------------------------------------------------------- + // commitFiles + // ----------------------------------------------------------------------- + + @Test + void commitFiles_success() { + stubFor(post(urlEqualTo(REPO_PATH + "/src")) + .willReturn(aResponse().withStatus(201).withBody(""))); + + assertDoesNotThrow(() -> + client.commitFiles("fix-branch", "fix: apply patch", Map.of("pom.xml", ""))); + } + + @Test + void commitFiles_returns400_throwsIOException() { + stubFor(post(urlEqualTo(REPO_PATH + "/src")) + .willReturn(aResponse().withStatus(400).withBody("{\"error\":{\"message\":\"Bad Request\"}}"))); + + assertThrows(Exception.class, () -> + client.commitFiles("fix-branch", "fix: apply patch", Map.of("pom.xml", ""))); + } + + // ----------------------------------------------------------------------- + // createPullRequest + // ----------------------------------------------------------------------- + + @Test + void createPullRequest_success_returnsPullRequest() throws Exception { + stubFor(post(urlEqualTo(REPO_PATH + "/pullrequests")) + .willReturn(aResponse() + .withStatus(201) + .withBody("{\"id\":7,\"links\":{\"html\":{\"href\":\"https://bitbucket.org/owner/repo/pull-requests/7\"}}}"))); + + PullRequest pr = client.createPullRequest("fix: auto-fix", "body text", "fix-branch", "main", false); + assertEquals(7, pr.number()); + assertEquals("https://bitbucket.org/owner/repo/pull-requests/7", pr.url()); + } + + @Test + void createPullRequest_returns422_throwsIOException() { + stubFor(post(urlEqualTo(REPO_PATH + "/pullrequests")) + .willReturn(aResponse().withStatus(422).withBody("{\"error\":{\"message\":\"Unprocessable Entity\"}}"))); + + assertThrows(Exception.class, () -> + client.createPullRequest("fix: auto-fix", "body text", "fix-branch", "main", false)); + } + + // ----------------------------------------------------------------------- + // deleteBranch + // ----------------------------------------------------------------------- + + @Test + void deleteBranch_success() { + stubFor(delete(urlEqualTo(REPO_PATH + "/refs/branches/fix-branch")) + .willReturn(aResponse().withStatus(204))); + + assertDoesNotThrow(() -> client.deleteBranch("fix-branch")); + } + + @Test + void deleteBranch_returns404_throwsIOException() { + stubFor(delete(urlEqualTo(REPO_PATH + "/refs/branches/fix-branch")) + .willReturn(aResponse().withStatus(404).withBody("{\"error\":{\"message\":\"Branch not found\"}}"))); + + assertThrows(Exception.class, () -> client.deleteBranch("fix-branch")); + } +} From 14c41bea9b27e081d7c5e75e8d2e2f74e9b8ee3c Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 22 Mar 2026 12:19:12 +0200 Subject: [PATCH 10/14] docs: update project documentation for auto-fix feature README.md: - Add "AI auto-fix" to Key Features - Add all autoFix* pipeline parameters to the parameters table - Add new "Auto-Fix: Automatic Pull Request Creation" usage section CONTRIBUTING.md: - Update architecture package structure tree to include full autofix/ and provider/ subtrees with all new classes - Note WireMock as a test dependency alongside JUnit 5 and Mockito .github/copilot-instructions.md: - Add AutoFixOrchestrator, AutoFixAction, FixAssistant, UnifiedDiffApplier, and SCM API clients to Key Components - Update Package Structure tree with complete autofix/ subtree - Update ExplainErrorStep description to list autoFix* parameters - Add autoFix and self-hosted GitLab examples to Pipeline Usage - Add autofix test files to Key Test Areas - Add wiremock-standalone to test dependencies .gitignore: - Add .gstack/ to ignore list Co-Authored-By: Claude Sonnet 4.6 --- .github/copilot-instructions.md | 62 +++++++++++++++++++++++++++------ .gitignore | 1 + CONTRIBUTING.md | 32 ++++++++++++++--- README.md | 55 +++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b11cc2a..e74c6e7b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,13 +11,18 @@ The Explain Error Plugin is a Jenkins plugin that provides AI-powered explanatio - **GlobalConfigurationImpl**: Main plugin configuration class with `@Symbol("explainError")` for Configuration as Code support, handles migration from legacy enum-based configuration - **BaseAIProvider**: Abstract base class for AI provider implementations with nested `Assistant` interface and `BaseProviderDescriptor` for extensibility - **OpenAIProvider** / **GeminiProvider** / **BedrockProvider** / **OllamaProvider**: LangChain4j-based AI service implementations with provider-specific configurations -- **ExplainErrorStep**: Pipeline step implementation for `explainError()` function (supports `logPattern`, `maxLines`, `language`, `customContext` parameters) +- **ExplainErrorStep**: Pipeline step implementation for `explainError()` function (supports `logPattern`, `maxLines`, `language`, `customContext`, `collectDownstreamLogs`, `downstreamJobPattern`, and all `autoFix*` parameters) - **ExplainErrorFolderProperty**: Folder-level AI provider override — allows teams to configure their own provider without touching global settings; walks up the folder hierarchy - **ConsoleExplainErrorAction**: Adds "Explain Error" button to console output for manual triggering - **ConsoleExplainErrorActionFactory**: TransientActionFactory that dynamically injects ConsoleExplainErrorAction into all runs (new and existing) - **ErrorExplanationAction**: Build action for storing and displaying AI explanations - **ConsolePageDecorator**: UI decorator to show explain button when conditions are met - **ErrorExplainer**: Core error analysis logic that coordinates AI providers and log parsing; resolves provider priority (step > folder > global) +- **AutoFixOrchestrator**: Coordinates the full AI auto-fix flow — AI suggestion → diff validation → branch creation → file commits → pull request; handles rollback on failure +- **AutoFixAction**: Build action that persists and displays the auto-fix PR URL in the Jenkins sidebar +- **FixAssistant**: LangChain4j AI service interface that requests a structured fix suggestion (fixable flag, file diffs, confidence score) +- **UnifiedDiffApplier**: Parses and applies unified diffs to file content with ±3-line fuzzy matching; validates diffs before any branch is created +- **ScmApiClient / GitHubApiClient / GitLabApiClient / BitbucketApiClient**: SCM-provider-specific REST API clients using JDK `HttpClient` (zero extra dependencies); support GitHub Enterprise, GitLab self-managed, and Bitbucket Cloud - **PipelineLogExtractor**: Extracts logs from the specific failing Pipeline step node (via `FlowGraphWalker`); integrates with optional `pipeline-graph-view` plugin for deep-linking - **JenkinsLogAnalysis**: Structured record for AI response (errorSummary, resolutionSteps, bestPractices, errorSignature) - **ExplanationException**: Custom exception for error explanation failures @@ -28,7 +33,7 @@ The Explain Error Plugin is a Jenkins plugin that provides AI-powered explanatio ``` src/main/java/io/jenkins/plugins/explain_error/ ├── GlobalConfigurationImpl.java # Plugin configuration & CasC + migration logic -├── ExplainErrorStep.java # Pipeline step (logPattern, maxLines, language, customContext) +├── ExplainErrorStep.java # Pipeline step (logPattern, maxLines, language, customContext, autoFix*) ├── ExplainErrorFolderProperty.java # Folder-level AI provider override ├── ErrorExplainer.java # Core error analysis logic (provider resolution) ├── PipelineLogExtractor.java # Failing step log extraction + pipeline-graph-view URL @@ -39,12 +44,29 @@ src/main/java/io/jenkins/plugins/explain_error/ ├── JenkinsLogAnalysis.java # Structured AI response record ├── ExplanationException.java # Custom exception for error handling ├── AIProvider.java # @Deprecated enum (backward compatibility) -└── provider/ - ├── BaseAIProvider.java # Abstract AI service with Assistant interface - ├── OpenAIProvider.java # OpenAI/LangChain4j implementation - ├── GeminiProvider.java # Google Gemini/LangChain4j implementation - ├── BedrockProvider.java # AWS Bedrock/LangChain4j implementation - └── OllamaProvider.java # Ollama/LangChain4j implementation +├── provider/ +│ ├── BaseAIProvider.java # Abstract AI service with Assistant interface +│ ├── OpenAIProvider.java # OpenAI/LangChain4j implementation +│ ├── GeminiProvider.java # Google Gemini/LangChain4j implementation +│ ├── BedrockProvider.java # AWS Bedrock/LangChain4j implementation +│ └── OllamaProvider.java # Ollama/LangChain4j implementation +└── autofix/ + ├── AutoFixOrchestrator.java # AI suggestion → branch → commits → PR (with rollback) + ├── AutoFixAction.java # Build action: persists & displays PR URL in sidebar + ├── AutoFixResult.java # Result value object (status + PR URL + message) + ├── AutoFixStatus.java # Enum: CREATED, FAILED, SKIPPED_*, NOT_APPLICABLE + ├── FixAssistant.java # LangChain4j interface for structured fix suggestions + ├── FixSuggestion.java # AI response: fixable flag, file diffs, confidence + ├── UnifiedDiffApplier.java # Applies unified diffs with ±3-line fuzzy matching + └── scm/ + ├── ScmApiClient.java # Interface: createBranch, commitFiles, createPullRequest + ├── ScmClientFactory.java # Creates right client based on ScmType + ├── ScmRepo.java # Value object: type + baseUrl + owner/repo + token + ├── ScmType.java # Enum: GITHUB, GITLAB, BITBUCKET + ├── GitHubApiClient.java # GitHub REST v3 — Git Trees API (atomic multi-file commit) + ├── GitLabApiClient.java # GitLab REST v4 — Commits API with actions array + ├── BitbucketApiClient.java # Bitbucket Cloud REST v2 — multipart /src commit + └── PullRequest.java # Value object: number + URL + branch names ``` ## Coding Standards @@ -119,6 +141,10 @@ Use `provider.setThrowError(true)` to simulate failures, `provider.getLastCustom - Folder-level provider override (`ExplainErrorFolderPropertyTest`) - Error explanation display (`ErrorExplanationActionTest`) - Log extraction (`PipelineLogExtractorTest`) +- Auto-fix orchestration paths (`autofix/AutoFixOrchestratorTest`) — uses Mockito; covers fixable/not-fixable/empty-changes/path-guard flows +- SCM API clients (`autofix/GitHubApiClientTest`, `GitLabApiClientTest`, `BitbucketApiClientTest`) — WireMock integration tests; cover happy paths, auth failures, retry on 429/5xx +- `ScmRepo` parsing (`autofix/ScmRepoTest`) — SSH/HTTPS URL parsing, `parseWithOverride`, token redaction in `toString()` +- Unified diff application (`autofix/UnifiedDiffApplierTest`) — add/remove/modify hunks, multi-hunk, empty file, fuzzy matching ## Build & Dependencies @@ -128,7 +154,7 @@ Use `provider.setThrowError(true)` to simulate failures, `provider.getLastCustom - LangChain4j: v1.11.0 (langchain4j, langchain4j-open-ai, langchain4j-google-ai-gemini, langchain4j-bedrock, langchain4j-ollama) - Key Jenkins dependencies: `jackson2-api`, `workflow-step-api`, `commons-lang3-api` - SLF4J and Jackson exclusions to avoid conflicts with Jenkins core -- Test dependencies: `workflow-cps`, `workflow-job`, `workflow-durable-task-step`, `workflow-basic-steps`, `test-harness` +- Test dependencies: `workflow-cps`, `workflow-job`, `workflow-durable-task-step`, `workflow-basic-steps`, `test-harness`, `wiremock-standalone` (for SCM API integration tests) - Key dependencies: `jackson2-api`, `workflow-step-api`, `commons-lang3-api` ### Commands @@ -183,6 +209,22 @@ pipeline { language: 'Chinese', // Response language (default: English) customContext: 'This is a Maven project' // Step-level context; overrides global customContext ) + + // Auto-fix: AI generates a code fix and opens a PR automatically + explainError( + autoFix: true, + autoFixCredentialsId: 'github-pat', // Jenkins credential with repo write access + autoFixAllowedPaths: 'pom.xml,*.yml', // Restrict files the AI may change + autoFixDraftPr: true // Open as draft PR (GitHub only) + ) + + // Auto-fix on a self-hosted GitLab instance + explainError( + autoFix: true, + autoFixCredentialsId: 'gitlab-pat', + autoFixScmType: 'gitlab', + autoFixGitlabUrl: 'https://gitlab.company.com' + ) } } } @@ -253,7 +295,7 @@ Create `src/test/java/io/jenkins/plugins/explain_error/provider/AnthropicProvide ### Step 5 — Update Documentation -- Add provider to `README.md` feature list and CasC YAML example +- Add provider to `README.md` feature list, Supported AI Providers section, and CasC YAML example - Update `copilot-instructions.md` provider list and Key Components ### Best Practices diff --git a/.gitignore b/.gitignore index 08343646..ccc1a05d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ work .settings .classpath .project +.gstack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 841ff12d..5a056deb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ Run `make help` to see all available test-related targets. ### Writing Tests -We use JUnit 5 and Mockito for testing. Examples: +We use JUnit 5, Mockito, and WireMock for testing. Examples: ```java @ExtendWith(MockitoExtension.class) @@ -141,11 +141,33 @@ The plugin follows these patterns: ``` src/main/java/io/jenkins/plugins/explain_error/ ├── GlobalConfigurationImpl.java # Main plugin class -├── ExplainErrorStep.java # Pipeline step implementation -├── AIService.java # AI communication service -├── ErrorExplainer.java # Error analysis logic +├── ExplainErrorStep.java # Pipeline step (explainError + autoFix parameters) +├── ErrorExplainer.java # Error analysis logic (provider resolution) ├── ConsoleExplainErrorAction.java # Console button action -└── ErrorExplanationAction.java # Build action for storing results +├── ErrorExplanationAction.java # Build action for storing results +├── provider/ +│ ├── BaseAIProvider.java # Abstract AI service base class +│ ├── OpenAIProvider.java # OpenAI / LangChain4j +│ ├── GeminiProvider.java # Google Gemini / LangChain4j +│ ├── BedrockProvider.java # AWS Bedrock / LangChain4j +│ └── OllamaProvider.java # Ollama / LangChain4j +└── autofix/ + ├── AutoFixOrchestrator.java # Coordinates AI suggestion → branch → PR flow + ├── AutoFixAction.java # Build action that stores and displays the PR URL + ├── AutoFixResult.java # Result value object (status + PR URL + message) + ├── AutoFixStatus.java # Enum: CREATED, FAILED, SKIPPED_*, NOT_APPLICABLE + ├── FixAssistant.java # LangChain4j AI service interface for fix suggestions + ├── FixSuggestion.java # Structured AI response (fixable, changes, confidence) + ├── UnifiedDiffApplier.java # Applies unified diffs to file content (fuzzy match) + └── scm/ + ├── ScmApiClient.java # Interface: createBranch, commitFiles, createPullRequest… + ├── ScmClientFactory.java # Factory: creates the right client from ScmRepo + ├── ScmRepo.java # Value object: SCM type + base URL + owner/repo + token + ├── ScmType.java # Enum: GITHUB, GITLAB, BITBUCKET + ├── GitHubApiClient.java # GitHub REST API v3 (Git Trees API for atomic commits) + ├── GitLabApiClient.java # GitLab REST API v4 (Commits API) + ├── BitbucketApiClient.java # Bitbucket Cloud REST API v2 + └── PullRequest.java # Value object returned by createPullRequest() ``` ### Adding New Features diff --git a/README.md b/README.md index bdcc8ce4..232569b7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Whether it’s a compilation error, test failure, or deployment hiccup, this plu * **One-click error analysis** on any console output * **Pipeline-ready** with a simple `explainError()` step * **AI-powered explanations** via OpenAI GPT models, Google Gemini or local Ollama models +* **AI auto-fix** — automatically opens a pull request on GitHub, GitLab, or Bitbucket with AI-generated code changes when a build fails * **Folder-level configuration** so teams can use project-specific settings * **Smart provider management** — LangChain4j handles most providers automatically * **Customizable**: set provider, model, API endpoint (enterprise-ready)[^1], log filters, and more @@ -242,6 +243,16 @@ post { | **customContext** | Additional instructions or context for the AI. Overrides global custom context if specified. | Uses global configuration | | **collectDownstreamLogs** | Whether to include logs from failed downstream jobs discovered via the `build` step or `Cause.UpstreamCause` | `false` | | **downstreamJobPattern** | Regular expression matched against downstream job full names. Used only when downstream collection is enabled. | `''` (collect none) | +| **autoFix** | Enable AI auto-fix: the plugin will attempt to generate and commit a code fix, then open a pull request | `false` | +| **autoFixCredentialsId** | Jenkins credentials ID for a personal access token with write access to the repository | `''` | +| **autoFixScmType** | SCM type override: `github`, `gitlab`, or `bitbucket`. Required for self-hosted instances whose hostname is not `github.com`, `gitlab.com`, or `bitbucket.org` | Auto-detected from remote URL | +| **autoFixGithubEnterpriseUrl** | Base URL of your GitHub Enterprise instance (e.g. `https://github.company.com`) | `''` (uses `api.github.com`) | +| **autoFixGitlabUrl** | Base URL of your self-hosted GitLab instance (e.g. `https://gitlab.company.com`) | `''` (uses `gitlab.com`) | +| **autoFixBitbucketUrl** | Base URL of your self-hosted Bitbucket instance | `''` (uses `api.bitbucket.org`) | +| **autoFixAllowedPaths** | Comma-separated list of file glob patterns the AI is permitted to modify | `pom.xml,build.gradle,*.yml,*.yaml,...` | +| **autoFixDraftPr** | Open the pull request as a draft (GitHub only) | `false` | +| **autoFixTimeoutSeconds** | Maximum seconds to wait for the auto-fix to complete | `60` | +| **autoFixPrTemplate** | Custom Markdown template for the PR body. Supports `{jobName}`, `{buildNumber}`, `{explanation}`, `{changesSummary}`, `{fixType}`, `{confidence}` placeholders | Built-in template | ```groovy explainError( @@ -273,6 +284,50 @@ Output appears in the sidebar of the failed job. ![Side Panel - AI Error Explanation](docs/images/side-panel.png) +### Auto-Fix: Automatic Pull Request Creation + +When `autoFix: true` is set, the plugin goes one step further than explaining the error — it asks the AI to generate a code fix, commits the changes to a new branch, and opens a pull request for your review. + +**Quick start:** + +```groovy +post { + failure { + explainError( + autoFix: true, + autoFixCredentialsId: 'github-pat' // Jenkins credential with repo write access + ) + } +} +``` + +The pull request is created on the same repository the build checks out from. The URL appears in the Jenkins build sidebar as soon as the PR is opened. + +**Self-hosted SCM (GitHub Enterprise / GitLab self-managed / Bitbucket Server):** + +```groovy +explainError( + autoFix: true, + autoFixCredentialsId: 'gitlab-pat', + autoFixScmType: 'gitlab', + autoFixGitlabUrl: 'https://gitlab.company.com' +) +``` + +**Restrict which files the AI may change** (recommended for production): + +```groovy +explainError( + autoFix: true, + autoFixCredentialsId: 'github-pat', + autoFixAllowedPaths: 'pom.xml,build.gradle,*.properties' +) +``` + +The AI will only propose changes to files matching the glob patterns. Any attempt to modify files outside the list is rejected before a branch is created. + +> **Note:** Auto-fix requires a personal access token (PAT) with write access to the repository. It does **not** use the SSH key used to check out the repository. + ### Method 2: Manual Console Analysis Works with Freestyle, Declarative, or any job type. From 9c04b18e5ade6153184fc5b4f05f2e5c92db030e Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Thu, 16 Apr 2026 08:49:00 +0300 Subject: [PATCH 11/14] Potential fix for pull request finding 'Unread local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../plugins/explain_error/autofix/UnifiedDiffApplier.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java index 402fc37f..fc6f7d6a 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/UnifiedDiffApplier.java @@ -79,7 +79,6 @@ public static String apply(String originalContent, String diff) { } // Apply the hunk: remove old lines, insert new lines - int insertionPoint = actualPos; int removedCount = 0; List insertLines = new ArrayList<>(); From a6b04a858aa8479e48f37f927dbb9fe4797412bc Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 16 Apr 2026 09:04:03 +0300 Subject: [PATCH 12/14] fix: resolve compilation errors in ErrorExplainer - Add missing 'lastErrorLogs' field declaration and populate it after log extraction in explainError() - Fix getResolvedProvider() to return resolveProvider(run).provider() instead of the ProviderResolution record directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/io/jenkins/plugins/explain_error/ErrorExplainer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java index 651a0378..2092c3bc 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ErrorExplainer.java @@ -27,6 +27,7 @@ public class ErrorExplainer { private String providerName; private String urlString; + private String lastErrorLogs; private final UsageRecorder usageRecorder; private static final Logger LOGGER = Logger.getLogger(ErrorExplainer.class.getName()); @@ -60,7 +61,7 @@ public String getLastErrorLogs() { */ @CheckForNull public BaseAIProvider getResolvedProvider(@CheckForNull Run run) { - return resolveProvider(run); + return resolveProvider(run).provider(); } public String explainError(Run run, TaskListener listener, String logPattern, int maxLines) { @@ -140,6 +141,7 @@ String explainError(Run run, TaskListener listener, String logPattern, int PipelineLogExtractor.ExtractionResult extractionResult = extractErrorLogs(run, maxLines, collectDownstreamLogs, downstreamJobPattern, authentication); String errorLogs = filterErrorLogs(extractionResult.logLines(), logPattern); + this.lastErrorLogs = errorLogs; inputLogLineCount = countLines(errorLogs); logExtractionSummary(listener, extractionResult, maxLines); From 2839f1bd93932b8ed057fefee46030efbdb659f6 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 16 Apr 2026 09:08:46 +0300 Subject: [PATCH 13/14] fix: guard auto-fix against null errorLogs and null provider If explainError() returns early (disabled, no provider, quota exceeded, or invalid config), lastErrorLogs is never set and getResolvedProvider() may return null. Passing these null values to AutoFixOrchestrator caused a NullPointerException. Added null checks that log a clear skip message and return the explanation result without attempting auto-fix. Also add missing BaseAIProvider import to ExplainErrorStep. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/explain_error/ExplainErrorStep.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java index 8108b0af..3f9b1363 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -6,6 +6,7 @@ import io.jenkins.plugins.explain_error.autofix.AutoFixOrchestrator; import io.jenkins.plugins.explain_error.autofix.AutoFixResult; import io.jenkins.plugins.explain_error.autofix.AutoFixStatus; +import io.jenkins.plugins.explain_error.provider.BaseAIProvider; import java.util.Arrays; import java.util.List; import java.util.Set; @@ -244,6 +245,17 @@ protected String run() throws Exception { if (step.isAutoFix()) { String errorLogs = explainer.getLastErrorLogs(); + BaseAIProvider provider = explainer.getResolvedProvider(run); + + if (errorLogs == null) { + listener.getLogger().println("[AutoFix] Skipped: no error logs available (explanation may have been disabled or skipped)."); + return explanation; + } + if (provider == null) { + listener.getLogger().println("[AutoFix] Skipped: no AI provider configured."); + return explanation; + } + AutoFixOrchestrator orchestrator = new AutoFixOrchestrator(); List allowedPaths = Arrays.stream(step.getAutoFixAllowedPaths().split(",")) .map(String::trim) @@ -253,7 +265,7 @@ protected String run() throws Exception { AutoFixResult fixResult = orchestrator.attemptAutoFix( run, errorLogs, - explainer.getResolvedProvider(run), + provider, step.getAutoFixCredentialsId(), step.getAutoFixScmType().isEmpty() ? null : step.getAutoFixScmType(), step.getAutoFixGithubEnterpriseUrl().isEmpty() ? null : step.getAutoFixGithubEnterpriseUrl(), From d4b6369e5f1187776418be472160017dc0d8e85f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 16 Apr 2026 16:05:43 +0300 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20eng=20review=20=E2=80=94=20Pipelin?= =?UTF-8?q?e=20SCM,=20thread=20pool,=20path=20traversal,=20early=20validat?= =?UTF-8?q?ion,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add autoFixRemoteUrl parameter to ExplainErrorStep so Pipeline jobs can provide the SCM remote URL directly, bypassing the AbstractProject-only extractRemoteUrl() reflection path (WorkflowJob fix) - Use dedicated daemon thread pool in AutoFixOrchestrator instead of the JVM ForkJoinPool.commonPool() to avoid starving Jenkins executor threads - Bump default autoFixTimeoutSeconds from 60 to 120 (AI + multi-file SCM operations routinely exceed 60s) - Fix path traversal guard in validateFilePath(): replace manual ../ string-contains check with Path.normalize().startsWith('..') to catch trailing '..' segments (e.g. 'src/main/..') - Add early credentialsId blank check before AI call to fail fast on misconfiguration without burning API tokens - Add unit tests for autoFix=true branch in ExplainErrorStepTest - Add tests for blank credentialsId early-exit and explicit remoteUrl bypass in AutoFixOrchestratorTest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../explain_error/ExplainErrorStep.java | 13 +++- .../autofix/AutoFixOrchestrator.java | 41 ++++++++--- .../ExplainErrorStep/config.jelly | 9 ++- .../explain_error/ExplainErrorStepTest.java | 35 ++++++++++ .../autofix/AutoFixOrchestratorTest.java | 69 +++++++++++++++++-- 5 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java index 3f9b1363..e5737d90 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java +++ b/src/main/java/io/jenkins/plugins/explain_error/ExplainErrorStep.java @@ -35,13 +35,14 @@ public class ExplainErrorStep extends Step { // Auto-fix fields private boolean autoFix = false; private String autoFixCredentialsId = ""; + private String autoFixRemoteUrl = ""; private String autoFixScmType = ""; private String autoFixGithubEnterpriseUrl = ""; private String autoFixGitlabUrl = ""; private String autoFixBitbucketUrl = ""; private String autoFixAllowedPaths = "pom.xml,build.gradle,build.gradle.kts,*.properties,*.yml,*.yaml,Jenkinsfile,Dockerfile,package.json,requirements.txt,go.mod"; private boolean autoFixDraftPr = false; - private int autoFixTimeoutSeconds = 60; + private int autoFixTimeoutSeconds = 120; private String autoFixPrTemplate = ""; @DataBoundConstructor @@ -126,6 +127,15 @@ public void setAutoFixCredentialsId(String autoFixCredentialsId) { this.autoFixCredentialsId = autoFixCredentialsId != null ? autoFixCredentialsId : ""; } + public String getAutoFixRemoteUrl() { + return autoFixRemoteUrl; + } + + @DataBoundSetter + public void setAutoFixRemoteUrl(String autoFixRemoteUrl) { + this.autoFixRemoteUrl = autoFixRemoteUrl != null ? autoFixRemoteUrl : ""; + } + public String getAutoFixScmType() { return autoFixScmType; } @@ -267,6 +277,7 @@ protected String run() throws Exception { errorLogs, provider, step.getAutoFixCredentialsId(), + step.getAutoFixRemoteUrl().isEmpty() ? null : step.getAutoFixRemoteUrl(), step.getAutoFixScmType().isEmpty() ? null : step.getAutoFixScmType(), step.getAutoFixGithubEnterpriseUrl().isEmpty() ? null : step.getAutoFixGithubEnterpriseUrl(), step.getAutoFixGitlabUrl().isEmpty() ? null : step.getAutoFixGitlabUrl(), diff --git a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java index 791c66ef..76931d26 100644 --- a/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java +++ b/src/main/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestrator.java @@ -21,6 +21,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.regex.Pattern; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -40,6 +42,16 @@ public class AutoFixOrchestrator { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** + * Dedicated thread pool for auto-fix operations. Uses daemon threads so the pool + * does not prevent JVM shutdown; avoids polluting the ForkJoinPool.commonPool(). + */ + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "jenkins-autofix"); + t.setDaemon(true); + return t; + }); + /** Matches {@code {word}} placeholder tokens in PR body templates. */ private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("\\{(\\w+)\\}"); @@ -80,6 +92,7 @@ public class AutoFixOrchestrator { * @param errorLogs the error logs to analyse * @param aiProvider the configured AI provider * @param credentialsId Jenkins credentials ID for SCM token (StringCredentials) + * @param remoteUrl explicit SCM remote URL; if null or blank, extracted from the job's SCM config * @param scmTypeOverride optional override for SCM type detection ("github"/"gitlab"/"bitbucket") * @param githubEnterpriseUrl optional GitHub Enterprise API base URL (e.g. https://ghe.example.com) * @param gitlabUrl optional self-hosted GitLab base URL @@ -95,6 +108,7 @@ public AutoFixResult attemptAutoFix( String errorLogs, BaseAIProvider aiProvider, String credentialsId, + String remoteUrl, String scmTypeOverride, String githubEnterpriseUrl, String gitlabUrl, @@ -111,7 +125,7 @@ public AutoFixResult attemptAutoFix( CompletableFuture future = CompletableFuture.supplyAsync(() -> { try { return doAttemptAutoFix( - run, errorLogs, aiProvider, credentialsId, + run, errorLogs, aiProvider, credentialsId, remoteUrl, scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl, allowedPathGlobs, draftPr, listener, createdBranchRef, prTemplate); } catch (Exception e) { @@ -119,7 +133,7 @@ public AutoFixResult attemptAutoFix( listener.getLogger().println("[AutoFix] Error: " + e.getMessage()); return AutoFixResult.failed("Auto-fix encountered an unexpected error: " + e.getMessage()); } - }); + }, EXECUTOR); try { return future.get(timeoutSeconds, TimeUnit.SECONDS); @@ -129,11 +143,12 @@ public AutoFixResult attemptAutoFix( // Best-effort branch cleanup if (createdBranchRef[0] != null) { try { - String remoteUrl = extractRemoteUrl(run); + String resolvedUrl = (remoteUrl != null && !remoteUrl.isBlank()) + ? remoteUrl : extractRemoteUrl(run); StringCredentials creds = CredentialsProvider.findCredentialById( credentialsId, StringCredentials.class, run, Collections.emptyList()); if (creds != null) { - ScmRepo repo = buildScmRepo(remoteUrl, creds.getSecret().getPlainText(), + ScmRepo repo = buildScmRepo(resolvedUrl, creds.getSecret().getPlainText(), scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl); ScmApiClient client = ScmClientFactory.create(repo); client.deleteBranch(createdBranchRef[0]); @@ -162,6 +177,7 @@ private AutoFixResult doAttemptAutoFix( String errorLogs, BaseAIProvider aiProvider, String credentialsId, + String remoteUrl, String scmTypeOverride, String githubEnterpriseUrl, String gitlabUrl, @@ -174,6 +190,11 @@ private AutoFixResult doAttemptAutoFix( listener.getLogger().println("[AutoFix] Requesting fix suggestion from AI provider..."); + // Early validation — fail before spending AI tokens + if (credentialsId == null || credentialsId.isBlank()) { + return AutoFixResult.failed("autoFixCredentialsId is required for auto-fix"); + } + // Step 1 — Get AI fix suggestion FixAssistant fixAssistant = aiProvider.createFixAssistant(); String rawJson = fixAssistant.suggestFix(errorLogs); @@ -222,8 +243,9 @@ private AutoFixResult doAttemptAutoFix( } // Step 5 — Detect SCM remote URL and credentials - String remoteUrl = extractRemoteUrl(run); - listener.getLogger().println("[AutoFix] Detected SCM remote: " + remoteUrl); + String resolvedRemoteUrl = (remoteUrl != null && !remoteUrl.isBlank()) + ? remoteUrl : extractRemoteUrl(run); + listener.getLogger().println("[AutoFix] SCM remote: " + resolvedRemoteUrl); StringCredentials creds = CredentialsProvider.findCredentialById( credentialsId, StringCredentials.class, run, Collections.emptyList()); @@ -233,7 +255,7 @@ private AutoFixResult doAttemptAutoFix( String token = creds.getSecret().getPlainText(); // Step 6 — Parse SCM repo (with enterprise overrides) - ScmRepo repo = buildScmRepo(remoteUrl, token, scmTypeOverride, + ScmRepo repo = buildScmRepo(resolvedRemoteUrl, token, scmTypeOverride, githubEnterpriseUrl, gitlabUrl, bitbucketUrl); listener.getLogger().println("[AutoFix] SCM type: " + repo.scmType() + ", owner: " + repo.owner() + ", repo: " + repo.repoName()); @@ -367,7 +389,10 @@ private String validateFilePath(String filePath, List allowedPathGlobs) if (filePath.startsWith("/")) { return "Absolute paths are not allowed: " + filePath; } - if (filePath.contains("../") || filePath.contains("..\\")) { + // Normalize to catch trailing ".." segments (e.g. "src/main/..") that would + // bypass a simple string-contains check. Also catches Windows-style "..\". + Path normalized = Path.of(filePath).normalize(); + if (normalized.startsWith("..")) { return "Path traversal is not allowed: " + filePath; } if (allowedPathGlobs != null && !allowedPathGlobs.isEmpty()) { diff --git a/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly b/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly index 982afeb1..e40f6355 100644 --- a/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly +++ b/src/main/resources/io/jenkins/plugins/explain_error/ExplainErrorStep/config.jelly @@ -36,6 +36,11 @@ + + + + @@ -57,8 +62,8 @@ - + description="Maximum time for the auto-fix operation (default: 120 seconds)."> + diff --git a/src/test/java/io/jenkins/plugins/explain_error/ExplainErrorStepTest.java b/src/test/java/io/jenkins/plugins/explain_error/ExplainErrorStepTest.java index 002c9c5f..03fbd450 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/ExplainErrorStepTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/ExplainErrorStepTest.java @@ -122,4 +122,39 @@ void testExplainErrorStepPassesLanguageToAI(JenkinsRule jenkins) throws Exceptio "language parameter should be forwarded to the AI provider"); } + // ------------------------------------------------------------------------- + // autoFix=true — null-guard and skip-path tests + // ------------------------------------------------------------------------- + + @Test + void testAutoFix_disabledByDefault_noAutoFixSideEffects(JenkinsRule jenkins) throws Exception { + // When autoFix is not set (default false), the auto-fix block is never entered + // and no credentialsId is required + TestProvider provider = new TestProvider(); + GlobalConfigurationImpl.get().setAiProvider(provider); + + WorkflowJob job = jenkins.createProject(WorkflowJob.class, "test-autofix-disabled"); + job.setDefinition(new CpsFlowDefinition( + "node { explainError() }", true)); + + WorkflowRun run = jenkins.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0)); + jenkins.assertLogNotContains("[AutoFix]", run); + } + + @Test + void testAutoFix_blankCredentials_logsSkipAndContinues(JenkinsRule jenkins) throws Exception { + // autoFix=true but no credentialsId → auto-fix fails early, step still returns explanation + TestProvider provider = new TestProvider(); + GlobalConfigurationImpl.get().setAiProvider(provider); + + WorkflowJob job = jenkins.createProject(WorkflowJob.class, "test-autofix-no-creds"); + job.setDefinition(new CpsFlowDefinition( + "node { explainError(autoFix: true, autoFixRemoteUrl: 'https://github.com/org/repo') }", true)); + + WorkflowRun run = jenkins.assertBuildStatus(Result.SUCCESS, job.scheduleBuild2(0)); + // The step must still return an explanation despite auto-fix failing + jenkins.assertLogContains("[AutoFix]", run); + jenkins.assertLogContains("autoFixCredentialsId", run); + } + } diff --git a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java index 9a1cc05c..38097925 100644 --- a/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java +++ b/src/test/java/io/jenkins/plugins/explain_error/autofix/AutoFixOrchestratorTest.java @@ -202,7 +202,7 @@ void attemptAutoFix_notFixable_returnsSkippedLowConfidence() { AutoFixResult result = orchestrator.attemptAutoFix( run, "some error logs", aiProvider, - "creds-id", null, null, null, null, + "creds-id", null, null, null, null, null, Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); @@ -222,7 +222,7 @@ void attemptAutoFix_fixableButLowConfidence_returnsSkippedLowConfidence() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, - "creds-id", null, null, null, null, + "creds-id", null, null, null, null, null, Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); @@ -242,7 +242,7 @@ void attemptAutoFix_emptyChanges_returnsSkipped() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, - "creds-id", null, null, null, null, + "creds-id", null, null, null, null, null, Collections.emptyList(), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus(), @@ -276,7 +276,7 @@ void attemptAutoFix_pathNotInAllowedList_returnsSkippedPathNotAllowed() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, - "creds-id", null, null, null, null, + "creds-id", null, null, null, null, null, List.of("pom.xml"), false, 30, listener, null); assertEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus()); @@ -317,10 +317,69 @@ void attemptAutoFix_pathMatchesAllowedGlob_doesNotReturnPathNotAllowed() { AutoFixResult result = orchestrator.attemptAutoFix( run, "error logs", aiProvider, - "creds-id", null, null, null, null, + "creds-id", null, null, null, null, null, List.of("pom.xml"), false, 30, listener, null); assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus(), "pom.xml matches the allowed glob and must not be rejected"); } + + // ----------------------------------------------------------------------- + // Early validation — blank credentialsId fails before AI call + // ----------------------------------------------------------------------- + + @Test + void attemptAutoFix_blankCredentialsId_returnsFailedBeforeAiCall() { + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "", null, null, null, null, null, + Collections.emptyList(), false, 30, listener, null); + + assertEquals(AutoFixStatus.FAILED, result.getStatus()); + assertTrue(result.getMessage().contains("autoFixCredentialsId"), + "Error message must mention the missing field"); + // AI was never called — blank creds detected before AI request + verify(fixAssistant, never()).suggestFix(anyString()); + } + + // ----------------------------------------------------------------------- + // extractRemoteUrl — explicit remoteUrl bypasses SCM reflection + // ----------------------------------------------------------------------- + + @Test + void attemptAutoFix_explicitRemoteUrl_bypassesScmExtraction() { + // fixable=true with a valid pom.xml change; provide an explicit remoteUrl + // so the code never needs to call run.getParent() for SCM extraction. + // The test verifies that with no AbstractProject parent, we still get past + // path guards (SCM extraction is the next step and would fail without remoteUrl). + String aiJson = """ + { + "fixable": true, + "explanation": "Config fix", + "confidence": "high", + "fixType": "config", + "changes": [ + { + "filePath": "pom.xml", + "action": "modify", + "unifiedDiff": "--- a/pom.xml\\n+++ b/pom.xml\\n@@ -1,1 +1,1 @@\\n-old\\n+new\\n", + "description": "Update version" + } + ] + } + """; + when(fixAssistant.suggestFix(anyString())).thenReturn(aiJson); + + // No parent mock — run.getParent() would fail if called for SCM extraction + // (CredentialsProvider.findCredentialById will return null → FAILED, not SKIPPED) + AutoFixResult result = orchestrator.attemptAutoFix( + run, "error logs", aiProvider, + "creds-id", "https://github.com/org/repo", null, null, null, null, + List.of("pom.xml"), false, 30, listener, null); + + // Must not be SKIPPED_PATH_NOT_ALLOWED — explicit URL bypassed SCM extraction + assertNotEquals(AutoFixStatus.SKIPPED_PATH_NOT_ALLOWED, result.getStatus()); + // The result will be FAILED (no credentials in test context) — not a path error + assertNotEquals(AutoFixStatus.SKIPPED_LOW_CONFIDENCE, result.getStatus()); + } }