diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java index ae8f2d9ed..a1efc1d8a 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java @@ -302,17 +302,30 @@ List getRepositories(@CheckForNull UserRoleInRepo * @throws IOException if there was a network communications error. * @throws InterruptedException if interrupted while waiting on remote communications. */ + @NonNull @Restricted(NoExternalUse.class) Iterable getDirectoryContent(BitbucketSCMFile parent) throws IOException, InterruptedException; /** * Return an input stream for the given file. * - * @param file and instance of SCM file + * @param file an instance of SCM file * @return the stream of the given {@link SCMFile} * @throws IOException if there was a network communications error. * @throws InterruptedException if interrupted while waiting on remote communications. */ @Restricted(NoExternalUse.class) InputStream getFileContent(BitbucketSCMFile file) throws IOException, InterruptedException; + + /** + * Return the metadata for the given file. + * + * @param file an instance of SCM file + * @return a {@link SCMFile} file with updated the metadata + * @throws IOException if there was a network communications error. + * @throws InterruptedException if interrupted while waiting on remote communications. + */ + @NonNull + @Restricted(NoExternalUse.class) + SCMFile getFile(@NonNull BitbucketSCMFile file) throws IOException, InterruptedException; } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClient.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClient.java index 7ed4edd77..c0bc2b7ea 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClient.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClient.java @@ -836,27 +836,23 @@ public Iterable getDirectoryContent(final BitbucketSCMFile parent) thro .set("path", parent.getPath()) .expand(); List result = new ArrayList<>(); - String response = getRequest(url); - BitbucketCloudPage page = JsonParser.mapper.readValue(response, - new TypeReference>(){}); - for(BitbucketRepositorySource source:page.getValues()){ - result.add(source.toBitbucketScmFile(parent)); - } + String pageURL = url; + BitbucketCloudPage page; + do { + String response = getRequest(pageURL); + page = JsonParser.mapper.readValue(response, new TypeReference>(){}); - while (!page.isLastPage()){ - response = getRequest(page.getNext()); - page = JsonParser.mapper.readValue(response, - new TypeReference>(){}); - for(BitbucketRepositorySource source:page.getValues()){ - result.add(source.toBitbucketScmFile(parent)); + for(BitbucketRepositorySource source : page.getValues()){ + result.add(source.toBitbucketSCMFile(parent)); } - } + pageURL = page.getNext(); + } while (!page.isLastPage()); return result; } @Override - public InputStream getFileContent(BitbucketSCMFile file) throws IOException, InterruptedException { + public InputStream getFileContent(@NonNull BitbucketSCMFile file) throws IOException, InterruptedException { String url = UriTemplate.fromTemplate(REPO_URL_TEMPLATE + "/src{/branchOrHash,path}") .set("owner", owner) .set("repo", repositoryName) @@ -865,4 +861,17 @@ public InputStream getFileContent(BitbucketSCMFile file) throws IOException, Int .expand(); return getRequestAsInputStream(url); } + + @Override + public SCMFile getFile(@NonNull BitbucketSCMFile file) throws IOException, InterruptedException { + String url = UriTemplate.fromTemplate(REPO_URL_TEMPLATE + "/src{/branchOrHash,path}?format=meta") + .set("owner", owner) + .set("repo", repositoryName) + .set("branchOrHash", file.getRef()) + .set("path", file.getPath()) + .expand(); + String response = getRequest(url); + BitbucketRepositorySource src = JsonParser.mapper.readValue(response, BitbucketRepositorySource.class); + return src.toBitbucketSCMFile((BitbucketSCMFile) file.parent()); + } } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositorySource.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositorySource.java index 5a84fd757..561e24b78 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositorySource.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositorySource.java @@ -68,23 +68,24 @@ public String getHash() { @JsonIgnore public boolean isDirectory() { - return type.equals("commit_directory"); + return "commit_directory".equals(type); } - public BitbucketSCMFile toBitbucketScmFile(BitbucketSCMFile parent){ + public BitbucketSCMFile toBitbucketSCMFile(BitbucketSCMFile parent) { SCMFile.Type fileType; - if(isDirectory()){ + if (isDirectory()) { fileType = SCMFile.Type.DIRECTORY; } else { fileType = SCMFile.Type.REGULAR_FILE; - for(String attribute: getAttributes()){ - if(attribute.equals("link")){ + for (String attribute : getAttributes()) { + if ("link".equals(attribute)) { fileType = SCMFile.Type.LINK; - } else if(attribute.equals("subrepository")){ + } else if ("subrepository".equals(attribute)) { fileType = SCMFile.Type.OTHER; // sub-module or sub-repo } } } return new BitbucketSCMFile(parent, path, fileType, hash); } + } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFile.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFile.java index 06634e77f..c1978264f 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFile.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFile.java @@ -28,13 +28,15 @@ import edu.umd.cs.findbugs.annotations.NonNull; import java.io.IOException; import java.io.InputStream; +import java.util.Collections; import jenkins.scm.api.SCMFile; -public class BitbucketSCMFile extends SCMFile { +public class BitbucketSCMFile extends SCMFile { private final BitbucketApi api; private String ref; private final String hash; + private boolean resolved; public String getRef() { return ref; @@ -44,26 +46,12 @@ public void setRef(String ref) { this.ref = ref; } - @Deprecated - public BitbucketSCMFile(BitbucketSCMFileSystem bitBucketSCMFileSystem, - BitbucketApi api, - String ref) { - this(bitBucketSCMFileSystem, api, ref, null); - } - - public BitbucketSCMFile(BitbucketSCMFileSystem bitBucketSCMFileSystem, - BitbucketApi api, - String ref, String hash) { - super(); + public BitbucketSCMFile(BitbucketApi api, String ref, String hash) { type(Type.DIRECTORY); this.api = api; this.ref = ref; this.hash = hash; - } - - @Deprecated - public BitbucketSCMFile(@NonNull BitbucketSCMFile parent, String name, Type type) { - this(parent, name, type, null); + this.resolved = false; } public BitbucketSCMFile(@NonNull BitbucketSCMFile parent, String name, Type type, String hash) { @@ -72,6 +60,8 @@ public BitbucketSCMFile(@NonNull BitbucketSCMFile parent, String name, Type type this.ref = parent.ref; this.hash = hash; type(type); + // method called by the client when list content folder + this.resolved = true; } public String getHash() { @@ -80,12 +70,12 @@ public String getHash() { @Override @NonNull - public Iterable children() throws IOException, - InterruptedException { + public Iterable children() throws IOException, InterruptedException { if (this.isDirectory()) { return api.getDirectoryContent(this); } else { - throw new IOException("Cannot get children from a regular file"); + // respect the interface javadoc + return Collections.emptyList(); } } @@ -101,19 +91,29 @@ public InputStream content() throws IOException, InterruptedException { @Override public long lastModified() throws IOException, InterruptedException { - // TODO: Return valid value when Tag support is implemented - return 0; + return 0L; } @Override @NonNull protected SCMFile newChild(String name, boolean assumeIsDirectory) { - return new BitbucketSCMFile(this, name, assumeIsDirectory?Type.DIRECTORY:Type.REGULAR_FILE, hash); + BitbucketSCMFile child = new BitbucketSCMFile(this, name, assumeIsDirectory ? Type.DIRECTORY : Type.REGULAR_FILE, hash); + child.resolved = false; + return child; } @Override @NonNull protected Type type() throws IOException, InterruptedException { + if (!resolved) { + try { + SCMFile metadata = api.getFile(this); + type(metadata.getType()); + } catch(IOException e) { + type(Type.NONEXISTENT); + } + resolved = true; + } return this.getType(); } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileSystem.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileSystem.java index 94621f31c..b8d4ba76b 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileSystem.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileSystem.java @@ -43,11 +43,11 @@ import hudson.Util; import hudson.model.Item; import hudson.model.Queue; -import hudson.model.queue.Tasks; import hudson.scm.SCM; import hudson.scm.SCMDescriptor; import hudson.security.ACL; import java.io.IOException; +import java.lang.annotation.Inherited; import jenkins.authentication.tokens.api.AuthenticationTokens; import jenkins.scm.api.SCMFile; import jenkins.scm.api.SCMFileSystem; @@ -69,13 +69,10 @@ protected BitbucketSCMFileSystem(BitbucketApi api, String ref, SCMRevision rev) } /** - * Return timestamp of last commit or of tag if its annotated tag. - * - * @return timestamp of last commit or of tag if its annotated tag + * {@link Inherited} */ @Override public long lastModified() throws IOException { - // TODO figure out how to implement this return 0L; } @@ -83,7 +80,7 @@ public long lastModified() throws IOException { @Override public SCMFile getRoot() { SCMRevision revision = getRevision(); - return new BitbucketSCMFile(this, api, ref, revision == null ? null : revision.toString()); + return new BitbucketSCMFile(api, ref, revision == null ? null : revision.toString()); } @Extension @@ -116,22 +113,24 @@ public SCMFileSystem build(@NonNull Item owner, @NonNull SCM scm, @CheckForNull } private static StandardCredentials lookupScanCredentials(@CheckForNull Item context, - @CheckForNull String scanCredentialsId, String serverUrl) { - if (Util.fixEmpty(scanCredentialsId) == null) { + @CheckForNull String scanCredentialsId, + String serverURL) { + scanCredentialsId = Util.fixEmpty(scanCredentialsId); + if (scanCredentialsId == null) { return null; } else { return CredentialsMatchers.firstOrNull( - CredentialsProvider.lookupCredentials( + CredentialsProvider.lookupCredentialsInItem( StandardCredentials.class, context, - context instanceof Queue.Task - ? Tasks.getDefaultAuthenticationOf((Queue.Task) context) - : ACL.SYSTEM, - URIRequirementBuilder.fromUri(serverUrl).build() + context instanceof Queue.Task task + ? task.getDefaultAuthentication2() + : ACL.SYSTEM2, + URIRequirementBuilder.fromUri(serverURL).build() ), CredentialsMatchers.allOf( CredentialsMatchers.withId(scanCredentialsId), - AuthenticationTokens.matcher(BitbucketAuthenticator.authenticationContext(serverUrl)) + AuthenticationTokens.matcher(BitbucketAuthenticator.authenticationContext(serverURL)) ) ); } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java index 4787827af..90572b7d3 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java @@ -36,8 +36,6 @@ import java.io.InputStream; import java.net.InetSocketAddress; import java.net.Proxy; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.TimeUnit; @@ -220,21 +218,21 @@ private void setClientProxyParams(String host, HttpClientBuilder builder) { @NonNull protected abstract CloseableHttpClient getClient(); - protected CloseableHttpResponse executeMethod(HttpHost host, - HttpRequestBase httpMethod, - boolean requireAuthentication) throws IOException { + protected CloseableHttpResponse executeMethod(HttpRequestBase request, boolean requireAuthentication) throws IOException { if (requireAuthentication && authenticator != null) { - authenticator.configureRequest(httpMethod); + authenticator.configureRequest(request); } - return getClient().execute(host, httpMethod, context); + // the Apache client determinate the host from request.getURI() + // in some cases like requests to mirror or avatar, the host could not be the same of configured in Jenkins + return getClient().execute(request, context); } - protected CloseableHttpResponse executeMethod(HttpHost host, HttpRequestBase httpMethod) throws IOException { - return executeMethod(host, httpMethod, true); + protected CloseableHttpResponse executeMethod(HttpRequestBase httpMethod) throws IOException { + return executeMethod(httpMethod, true); } protected String doRequest(HttpRequestBase request, boolean requireAuthentication) throws IOException { - try (CloseableHttpResponse response = executeMethod(getHost(), request, requireAuthentication)) { + try (CloseableHttpResponse response = executeMethod(request, requireAuthentication)) { int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == HttpStatus.SC_NOT_FOUND) { throw new FileNotFoundException("URL: " + request.getURI()); @@ -250,7 +248,7 @@ protected String doRequest(HttpRequestBase request, boolean requireAuthenticatio throw buildResponseException(response, content); } return content; - } catch (BitbucketRequestException e) { + } catch (FileNotFoundException | BitbucketRequestException e) { throw e; } catch (IOException e) { throw new IOException("Communication error for url: " + request, e); @@ -276,19 +274,7 @@ private void release(HttpRequestBase method) { */ protected InputStream getRequestAsInputStream(String path) throws IOException { HttpGet httpget = new HttpGet(path); - HttpHost host = getHost(); - - // Extract host from URL, if present - try { - URI uri = new URI(host.toURI()); - if (uri.isAbsolute() && ! uri.isOpaque()) { - host = HttpHost.create(uri.getScheme() + "://" + uri.getAuthority()); - } - } catch (URISyntaxException ex) { - // use default - } - - CloseableHttpResponse response = executeMethod(host, httpget); + CloseableHttpResponse response = executeMethod(httpget); int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == HttpStatus.SC_NOT_FOUND) { EntityUtils.consume(response.getEntity()); @@ -303,7 +289,7 @@ protected InputStream getRequestAsInputStream(String path) throws IOException { protected int headRequestStatus(String path) throws IOException { HttpHead httpHead = new HttpHead(path); - try (CloseableHttpResponse response = executeMethod(getHost(), httpHead)) { + try (CloseableHttpResponse response = executeMethod(httpHead)) { EntityUtils.consume(response.getEntity()); return response.getStatusLine().getStatusCode(); } catch (IOException e) { diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java index 4a4e02b7d..81fe7ddad 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java @@ -952,7 +952,7 @@ protected CloseableHttpClient getClient() { protected HttpHost getHost() { String url = baseURL; try { - // it's really needed? + // it's needed because the serverURL can contains a context root different than '/' and the HttpHost must contains only schema, host and port URL tmp = new URL(baseURL); String schema = tmp.getProtocol() == null ? "http" : tmp.getProtocol(); return new HttpHost(tmp.getHost(), tmp.getPort(), schema); @@ -1050,4 +1050,21 @@ private Map collectLines(String response, final List line return content; } + @NonNull + @Override + public SCMFile getFile(@NonNull BitbucketSCMFile file) throws IOException, InterruptedException { + String branchOrHash = file.getHash().contains("+") ? file.getRef() : file.getHash(); + String url = UriTemplate.fromTemplate(this.baseURL + API_BROWSE_PATH + "{&type,blame,size") + .set("owner", getUserCentricOwner()) + .set("repo", repositoryName) + .set("path", file.getPath().split(Operator.PATH.getSeparator())) + .set("at", branchOrHash) + .set("type", true) + .set("blame", false) + .expand(); + String response = getRequest(url); + // TODO deserialize response... + return new BitbucketSCMFile(this, file.getRef(), file.getHash()); + } + } diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java index a4edbc7a6..939c99704 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java @@ -27,12 +27,12 @@ import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator; import com.cloudbees.jenkins.plugins.bitbucket.impl.util.BitbucketApiUtils; import com.cloudbees.jenkins.plugins.bitbucket.server.client.BitbucketServerAPIClient; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import org.apache.commons.io.IOUtils; import org.apache.http.HttpEntity; -import org.apache.http.HttpHost; import org.apache.http.StatusLine; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpRequestBase; @@ -53,7 +53,7 @@ default void request(HttpRequestBase request) { default CloseableHttpResponse loadResponseFromResources(Class resourceBase, String path, String payloadPath) throws IOException { try (InputStream json = resourceBase.getResourceAsStream(payloadPath)) { if (json == null) { - throw new IllegalStateException("Payload for the REST path " + path + " could not be found: " + payloadPath); + throw new FileNotFoundException("Payload for the REST path " + path + " could not be found: " + payloadPath); } HttpEntity entity = mock(HttpEntity.class); String jsonString = IOUtils.toString(json, StandardCharsets.UTF_8); @@ -104,19 +104,17 @@ private BitbucketServerIntegrationClient(String payloadRootPath, String baseURL, } @Override - protected CloseableHttpResponse executeMethod(HttpHost host, - HttpRequestBase httpMethod, - boolean requireAuthentication) throws IOException { - String path = httpMethod.getURI().toString(); - audit.request(httpMethod); + protected CloseableHttpResponse executeMethod(HttpRequestBase request, boolean requireAuthentication) throws IOException { + String requestURI = request.getURI().toString(); + audit.request(request); - String payloadPath = path.substring(path.indexOf("/rest/")) + String payloadPath = requestURI.substring(requestURI.indexOf("/rest/")) .replace("/rest/api/", "") .replace("/rest/", "") .replace('/', '-').replaceAll("[=%&?]", "_"); payloadPath = payloadRootPath + payloadPath + ".json"; - return loadResponseFromResources(getClass(), path, payloadPath); + return loadResponseFromResources(getClass(), requestURI, payloadPath); } @Override @@ -155,9 +153,7 @@ private BitbucketClouldIntegrationClient(String payloadRootPath, String owner, S } @Override - protected CloseableHttpResponse executeMethod(HttpHost host, - HttpRequestBase httpMethod, - boolean requireAuthentication) throws IOException { + protected CloseableHttpResponse executeMethod(HttpRequestBase httpMethod, boolean requireAuthentication) throws IOException { String path = httpMethod.getURI().toString(); audit.request(httpMethod); diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileTest.java new file mode 100644 index 000000000..71faf756f --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/filesystem/BitbucketSCMFileTest.java @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Nikolas Falco + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.cloudbees.jenkins.plugins.bitbucket.filesystem; + +import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketApi; +import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory; +import java.io.FileNotFoundException; +import jenkins.scm.api.SCMFile.Type; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BitbucketSCMFileTest { + + @WithJenkins + @Issue("JENKINS-75157") + @Test + void test(JenkinsRule r) { + BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient("https://acme.bitbucket.com"); + + BitbucketSCMFile parent = new BitbucketSCMFile(client, "master", "hash"); + BitbucketSCMFile file = new BitbucketSCMFile(parent, "pipeline_config.groovy", Type.REGULAR_FILE, "046d9a3c1532acf4cf08fe93235c00e4d673c1d2"); + assertThatThrownBy(file::content).isInstanceOf(FileNotFoundException.class); + } +}