diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java index 237b19b..6e0f669 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java @@ -32,6 +32,7 @@ import hudson.model.Item; import hudson.model.Job; import hudson.model.Result; +import hudson.model.Run; import hudson.plugins.git.BranchSpec; import hudson.plugins.git.GitSCM; import hudson.plugins.git.GitStatus; @@ -67,22 +68,33 @@ public static boolean isGitPluginInstalled() { annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) public List getJobScm( @ToolParam(description = "Full path of the Jenkins job (e.g., 'folder/job-name')") String jobFullName) { - var job = Jenkins.get().getItemByFullName(jobFullName, Job.class); - if (job instanceof SCMTriggerItem scmItem) { - if (job.hasPermission(Item.EXTENDED_READ)) { - return scmItem.getSCMs().stream() - .map(scm -> { - Object result = null; - if (scm.getType().equals("hudson.plugins.git.GitSCM")) { - result = GitScmUtil.extractGitScmInfo(scm); - } - return result; - }) - .filter(Objects::nonNull) - .toList(); - } + var maybeJob = resolveVisibleJob(jobFullName); + if (maybeJob.isEmpty()) + return List.of(); + var job = maybeJob.get(); + if (!job.hasPermission(Item.EXTENDED_READ)) { + logLookupFailure("missing EXTENDED_READ", jobFullName, null, null); + return List.of(); + } + if (!(job instanceof SCMTriggerItem scmItem)) { + logLookupFailure("job does not expose SCM configuration", jobFullName, null, null); + return List.of(); + } + + try { + return scmItem.getSCMs().stream() + .map(scm -> { + Object result = null; + if (scm.getType().equals("hudson.plugins.git.GitSCM")) + result = GitScmUtil.extractGitScmInfo(scm); + return result; + }) + .filter(Objects::nonNull) + .toList(); + } catch (RuntimeException e) { + logLookupFailure("failed to extract SCM details", jobFullName, null, e); + return List.of(); } - return List.of(); } @Tool( @@ -95,14 +107,70 @@ public List getBuildScm( description = "Build number (optional, if not provided, updates the last build)", required = false) Integer buildNumber) { - return getBuildByNumberOrLast(jobFullName, buildNumber) - .map(build -> { - if (isGitPluginInstalled()) { - return List.of(GitScmUtil.extractGitScmInfo(build)); - } - return List.of(); - }) - .orElse(List.of()); + var maybeJob = resolveVisibleJob(jobFullName); + if (maybeJob.isEmpty()) + return List.of(); + Run build = resolveBuild(maybeJob.get(), buildNumber).orElse(null); + if (build == null) + return List.of(); + + if (!isGitPluginInstalled()) { + logLookupFailure("git plugin is not installed or inactive", jobFullName, buildNumber, null); + return List.of(); + } + + try { + return Optional.ofNullable(GitScmUtil.extractGitScmInfo(build)).map(List::of).orElse(List.of()); + } catch (RuntimeException e) { + logLookupFailure("failed to extract build SCM details", jobFullName, buildNumber, e); + return List.of(); + } + } + + private Optional> resolveVisibleJob(String jobFullName) { + var job = Jenkins.get().getItemByFullName(jobFullName, Job.class); + if (job == null) { + logLookupFailure("job not found or not visible", jobFullName, null, null); + return Optional.empty(); + } + return Optional.of(job); + } + + private Optional> resolveBuild(Job job, @Nullable Integer buildNumber) { + Run build; + if (buildNumber == null || buildNumber <= 0) { + build = job.getLastBuild(); + } else { + build = job.getBuildByNumber(buildNumber); + } + + if (build == null) { + int requestedBuildNumber = (buildNumber == null || buildNumber <= 0) ? -1 : buildNumber; + logLookupFailure("build not found", job.getFullName(), requestedBuildNumber, null); + return Optional.empty(); + } + return Optional.of(build); + } + + private void logLookupFailure( + String reason, String jobFullName, @Nullable Integer buildNumber, @Nullable Throwable error) { + if (!LOGGER.isLoggable(Level.WARNING)) + return; + var jenkins = Jenkins.get(); + String user = jenkins.getAuthentication2().getName(); + if (error == null) { + LOGGER.log( + Level.WARNING, + "SCM lookup failed: reason={0}, jobFullName={1}, buildNumber={2}, user={3}, rootUrl={4}", + new Object[] {reason, jobFullName, buildNumber, user, jenkins.getRootUrl()}); + return; + } + LOGGER.log( + Level.WARNING, + String.format( + "SCM lookup failed: reason=%s, jobFullName=%s, buildNumber=%s, user=%s, rootUrl=%s", + reason, jobFullName, buildNumber, user, jenkins.getRootUrl()), + error); } @Tool( diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/util/JenkinsUtil.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/util/JenkinsUtil.java index 2d3fbff..800eca6 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/extensions/util/JenkinsUtil.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/util/JenkinsUtil.java @@ -30,20 +30,32 @@ import hudson.model.Run; import jakarta.annotation.Nullable; import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; import jenkins.model.Jenkins; import lombok.NonNull; public class JenkinsUtil { + private static final Logger LOGGER = Logger.getLogger(JenkinsUtil.class.getName()); + public static Optional getBuildByNumberOrLast(@NonNull String fullJobName, @Nullable Integer buildNumber) { - return Optional.of(Jenkins.get()) - .map(jenkins -> jenkins.getItemByFullName(fullJobName, Job.class)) - .map(job -> { - if (buildNumber == null || buildNumber <= 0) { - return job.getLastBuild(); - } else { - return job.getBuildByNumber(buildNumber); - } - }); + var jenkins = Jenkins.get(); + var job = jenkins.getItemByFullName(fullJobName, Job.class); + if (job == null) { + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.log( + Level.FINE, + "Build lookup failed: job not found or not visible. jobFullName={0}, buildNumber={1}, user={2}, rootUrl={3}", + new Object[] { + fullJobName, buildNumber, jenkins.getAuthentication2().getName(), jenkins.getRootUrl() + }); + } + return Optional.empty(); + } + + if (buildNumber == null || buildNumber <= 0) + return Optional.ofNullable(job.getLastBuild()); + return Optional.ofNullable(job.getBuildByNumber(buildNumber)); } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtensionTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtensionTest.java index db234e0..5213c0d 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtensionTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtensionTest.java @@ -116,6 +116,65 @@ void testGetBuildScm( } } + @McpClientTest + void testGetBuildScmReturnsEmptyForNonGitBuild(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) + throws Exception { + FreeStyleProject project = jenkins.createFreeStyleProject("non-git-build-project"); + jenkins.buildAndAssertSuccess(project); + + try (var client = jenkinsMcpClientBuilder.jenkins(jenkins).build()) { + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "getBuildScm", Map.of("jobFullName", project.getFullName(), "buildNumber", 1)); + + var response = client.callTool(request); + + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(response.content()).first().isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).contains("no results were found"); + }); + } + } + + @McpClientTest + void testGetJobScmReturnsEmptyWhenJobIsNotVisibleOrMissing( + JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) throws Exception { + try (var client = jenkinsMcpClientBuilder.jenkins(jenkins).build()) { + McpSchema.CallToolRequest request = + new McpSchema.CallToolRequest("getJobScm", Map.of("jobFullName", "missing/folder/job")); + + var response = client.callTool(request); + + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(response.content()).first().isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).contains("no results were found"); + }); + } + } + + @McpClientTest + void testGetBuildScmReturnsEmptyWhenBuildDoesNotExist( + JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) throws Exception { + FreeStyleProject project = jenkins.createFreeStyleProject("missing-build-project"); + + try (var client = jenkinsMcpClientBuilder.jenkins(jenkins).build()) { + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "getBuildScm", Map.of("jobFullName", project.getFullName(), "buildNumber", 9999)); + + var response = client.callTool(request); + + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(response.content()).first().isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).contains("no results were found"); + }); + } + } + @McpClientTest void testGetBuildChangeSets( JenkinsRule jenkins, GitSampleRepoRule gitRepo, JenkinsMcpClientBuilder jenkinsMcpClientBuilder)