diff --git a/src/main/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactory.java b/src/main/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactory.java index b6c3adf..544303d 100644 --- a/src/main/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactory.java +++ b/src/main/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactory.java @@ -13,4 +13,10 @@ public static URI create(String baseUri, String commit) { String uri = String.join("/", tidyBase, "rest/build-status/1.0/commits", commit); return URI.create(uri); } + + public static URI create(String baseUri, String projectKey, String repoSlug, String commit) { + String tidyBase = StringUtils.removeEnd(baseUri, "/"); + String uri = String.join("/", tidyBase, "rest/api/latest/projects", projectKey, "repos", repoSlug, "commits", commit, "builds"); + return URI.create(uri); + } } diff --git a/src/main/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifier.java b/src/main/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifier.java index 74e0bc6..c03fcd5 100644 --- a/src/main/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifier.java +++ b/src/main/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifier.java @@ -60,10 +60,11 @@ class DefaultApacheHttpNotifier implements HttpNotifier { try (CloseableHttpClient client = getHttpClient(logger, uri, settings.isIgnoreUnverifiedSSL())) { HttpPost req = createRequest(uri, payload, settings.getCredentials(), context); HttpResponse res = client.execute(req); - if (res.getStatusLine().getStatusCode() != 204) { - return NotificationResult.newFailure(EntityUtils.toString(res.getEntity())); - } else { + int statusCode = res.getStatusLine().getStatusCode(); + if (statusCode >= 200 && statusCode < 300) { return NotificationResult.newSuccess(); + } else { + return NotificationResult.newFailure(EntityUtils.toString(res.getEntity())); } } catch (Exception e) { LOGGER.warn("{} failed to send {} to Bitbucket Server at {}", context.getRunId(), payload, uri, e); diff --git a/src/main/java/org/jenkinsci/plugins/stashNotifier/StashNotifier.java b/src/main/java/org/jenkinsci/plugins/stashNotifier/StashNotifier.java index 48d5050..23d7d8b 100644 --- a/src/main/java/org/jenkinsci/plugins/stashNotifier/StashNotifier.java +++ b/src/main/java/org/jenkinsci/plugins/stashNotifier/StashNotifier.java @@ -862,7 +862,15 @@ protected NotificationResult notifyStash( Credentials stringCredentials = getCredentials(StringCredentials.class, run.getParent()); - URI uri = BuildStatusUriFactory.create(stashURL, commitSha1); + String[] repoInfo = resolveBitbucketRepo(run, logger); + URI uri; + if (repoInfo != null) { + uri = BuildStatusUriFactory.create(stashURL, repoInfo[0], repoInfo[1], commitSha1); + logger.println("Using repo-scoped builds API: " + uri); + } else { + uri = BuildStatusUriFactory.create(stashURL, commitSha1); + logger.println("Using legacy build-status API: " + uri); + } NotificationSettings settings = new NotificationSettings( ignoreUnverifiedSSLPeer || getDescriptor().isIgnoreUnverifiedSsl(), stringCredentials != null ? stringCredentials : usernamePasswordCredentials @@ -1023,11 +1031,13 @@ private JSONObject createNotificationPayload( TaskListener listener) { JSONObject json = new JSONObject(); + String buildKey = abbreviate(getBuildKey(run, listener), MAX_FIELD_LENGTH); json.put("state", state.name()); - json.put("key", abbreviate(getBuildKey(run, listener), MAX_FIELD_LENGTH)); + json.put("key", buildKey); json.put("name", abbreviate(getBuildName(run), MAX_FIELD_LENGTH)); json.put("description", abbreviate(getBuildDescription(run, state), MAX_FIELD_LENGTH)); json.put("url", abbreviate(getBuildUrl(run), MAX_URL_FIELD_LENGTH)); + json.put("parent", buildKey); return json; } @@ -1109,6 +1119,87 @@ private static String idOf(Run run) { return run != null ? run.getExternalizableId() : "(absent run)"; } + /** + * Extracts the Bitbucket project key and repository slug from a Git remote URL. + * Supports common Bitbucket Server URL formats: + * + * + * @param remoteUrl the Git remote URL + * @return a two-element array [projectKey, repoSlug], or null if parsing fails + */ + static String[] parseBitbucketRemoteUrl(String remoteUrl) { + if (remoteUrl == null || remoteUrl.isEmpty()) { + return null; + } + + // Remove trailing .git + String url = remoteUrl.replaceAll("\\.git$", ""); + + // Handle SCP-style: git@host:PROJECT/repo + if (url.matches("^[^/]+@[^:]+:.+/.+$") && !url.startsWith("ssh://")) { + String path = url.substring(url.indexOf(':') + 1); + String[] parts = path.split("/"); + if (parts.length >= 2) { + return new String[]{parts[parts.length - 2], parts[parts.length - 1]}; + } + return null; + } + + // Handle URL-style (https://, ssh://) + // Extract path and get last two segments + String path; + try { + URI uri = URI.create(url); + path = uri.getPath(); + } catch (Exception e) { + return null; + } + + if (path == null || path.isEmpty()) { + return null; + } + + // Remove /scm/ prefix if present (Bitbucket Server HTTPS clone URLs use /scm/) + path = path.replaceFirst("^/scm/", "/"); + + // Remove leading slash and split + path = path.replaceFirst("^/", ""); + String[] segments = path.split("/"); + if (segments.length >= 2) { + return new String[]{segments[segments.length - 2], segments[segments.length - 1]}; + } + + return null; + } + + /** + * Resolves the Bitbucket project key and repository slug for the current build. + * Uses manually configured values if available, otherwise auto-detects from Git remote URL. + * + * @param run the current build run + * @param logger the logger for output + * @return a two-element array [projectKey, repoSlug], or null if not resolvable + */ + private String[] resolveBitbucketRepo(Run run, PrintStream logger) { + for (BuildData buildData : run.getActions(BuildData.class)) { + for (String remoteUrl : buildData.getRemoteUrls()) { + String[] parsed = parseBitbucketRemoteUrl(remoteUrl); + if (parsed != null) { + logger.println("Auto-detected Bitbucket project: " + parsed[0] + ", repo: " + parsed[1] + " from " + remoteUrl); + return parsed; + } + } + } + + logger.println("Could not determine Bitbucket project/repo - falling back to legacy build-status API"); + return null; + } + /** * Returns the build name to be pushed. This will select the specifically overwritten build name * or get the build name from the {@link Run}. diff --git a/src/test/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactoryTest.java b/src/test/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactoryTest.java index 2f848c3..357dc87 100644 --- a/src/test/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactoryTest.java +++ b/src/test/java/org/jenkinsci/plugins/stashNotifier/BuildStatusUriFactoryTest.java @@ -10,7 +10,7 @@ class BuildStatusUriFactoryTest { @Test - void shouldHandleTrailingSlash() { + void shouldCreateLegacyUriWithTrailingSlash() { String baseUri = "http://localhost:12345/"; URI expected = URI.create("http://localhost:12345/rest/build-status/1.0/commits/25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); URI actual = BuildStatusUriFactory.create(baseUri, "25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); @@ -18,7 +18,7 @@ void shouldHandleTrailingSlash() { } @Test - void shouldHandleNoTrailingSlash() { + void shouldCreateLegacyUriWithNoTrailingSlash() { String baseUri = "http://localhost:12345"; URI expected = URI.create("http://localhost:12345/rest/build-status/1.0/commits/25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); URI actual = BuildStatusUriFactory.create(baseUri, "25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); @@ -26,7 +26,7 @@ void shouldHandleNoTrailingSlash() { } @Test - void shouldHandleBasePathTrailingSlash() { + void shouldCreateLegacyUriWithBasePathTrailingSlash() { String baseUri = "http://localhost:12345/some-path/"; URI expected = URI.create("http://localhost:12345/some-path/rest/build-status/1.0/commits/25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); URI actual = BuildStatusUriFactory.create(baseUri, "25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); @@ -34,10 +34,34 @@ void shouldHandleBasePathTrailingSlash() { } @Test - void shouldHandleBasePathNoTrailingSlash() { + void shouldCreateLegacyUriWithBasePathNoTrailingSlash() { String baseUri = "http://localhost:12345/some-path"; URI expected = URI.create("http://localhost:12345/some-path/rest/build-status/1.0/commits/25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); URI actual = BuildStatusUriFactory.create(baseUri, "25a4b3c9b494fc7ac65b80e3b0ecce63f235f20d"); assertThat(actual, equalTo(expected)); } + + @Test + void shouldCreateRepoScopedUri() { + String baseUri = "http://localhost:12345"; + URI expected = URI.create("http://localhost:12345/rest/api/latest/projects/PROJ/repos/my-repo/commits/abc123/builds"); + URI actual = BuildStatusUriFactory.create(baseUri, "PROJ", "my-repo", "abc123"); + assertThat(actual, equalTo(expected)); + } + + @Test + void shouldCreateRepoScopedUriWithTrailingSlash() { + String baseUri = "http://localhost:12345/"; + URI expected = URI.create("http://localhost:12345/rest/api/latest/projects/PROJ/repos/my-repo/commits/abc123/builds"); + URI actual = BuildStatusUriFactory.create(baseUri, "PROJ", "my-repo", "abc123"); + assertThat(actual, equalTo(expected)); + } + + @Test + void shouldCreateRepoScopedUriWithBasePath() { + String baseUri = "http://localhost:12345/bitbucket"; + URI expected = URI.create("http://localhost:12345/bitbucket/rest/api/latest/projects/PROJ/repos/my-repo/commits/25a4b3c9/builds"); + URI actual = BuildStatusUriFactory.create(baseUri, "PROJ", "my-repo", "25a4b3c9"); + assertThat(actual, equalTo(expected)); + } } diff --git a/src/test/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifierTest.java b/src/test/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifierTest.java index 0e71893..c2289ac 100644 --- a/src/test/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifierTest.java +++ b/src/test/java/org/jenkinsci/plugins/stashNotifier/DefaultApacheHttpNotifierTest.java @@ -156,17 +156,35 @@ private NotificationResult notifyStash(int statusCode) throws Exception { } @Test - void notifyStash_success() throws Exception { + void notifyStash_success204() throws Exception { NotificationResult notificationResult = notifyStash(204); assertThat(notificationResult.indicatesSuccess, is(true)); } + @Test + void notifyStash_success200() throws Exception { + NotificationResult notificationResult = notifyStash(200); + assertThat(notificationResult.indicatesSuccess, is(true)); + } + + @Test + void notifyStash_success201() throws Exception { + NotificationResult notificationResult = notifyStash(201); + assertThat(notificationResult.indicatesSuccess, is(true)); + } + @Test void notifyStash_fail() throws Exception { NotificationResult notificationResult = notifyStash(400); assertThat(notificationResult.indicatesSuccess, is(false)); } + @Test + void notifyStash_fail500() throws Exception { + NotificationResult notificationResult = notifyStash(500); + assertThat(notificationResult.indicatesSuccess, is(false)); + } + @Test void notifyStashUsesRequestParameters() throws Exception { notifyStash(204); diff --git a/src/test/java/org/jenkinsci/plugins/stashNotifier/StashNotifierTest.java b/src/test/java/org/jenkinsci/plugins/stashNotifier/StashNotifierTest.java index 8fa3bd9..6c1686b 100644 --- a/src/test/java/org/jenkinsci/plugins/stashNotifier/StashNotifierTest.java +++ b/src/test/java/org/jenkinsci/plugins/stashNotifier/StashNotifierTest.java @@ -1014,4 +1014,49 @@ void setBuildStatus_null() { sn.setBuildStatus(null); assertThat(sn.getBuildStatus(), equalTo(StashBuildState.SUCCESSFUL)); } + + @Test + void parseBitbucketRemoteUrl_https_with_scm() { + String[] result = StashNotifier.parseBitbucketRemoteUrl("https://bitbucket.example.com/scm/PROJ/my-repo.git"); + assertThat(result[0], equalTo("PROJ")); + assertThat(result[1], equalTo("my-repo")); + } + + @Test + void parseBitbucketRemoteUrl_ssh() { + String[] result = StashNotifier.parseBitbucketRemoteUrl("ssh://git@bitbucket.example.com:7999/PROJ/my-repo.git"); + assertThat(result[0], equalTo("PROJ")); + assertThat(result[1], equalTo("my-repo")); + } + + @Test + void parseBitbucketRemoteUrl_scp_style() { + String[] result = StashNotifier.parseBitbucketRemoteUrl("git@bitbucket.example.com:PROJ/my-repo.git"); + assertThat(result[0], equalTo("PROJ")); + assertThat(result[1], equalTo("my-repo")); + } + + @Test + void parseBitbucketRemoteUrl_personal_repo() { + String[] result = StashNotifier.parseBitbucketRemoteUrl("https://bitbucket.example.com/scm/~username/my-repo.git"); + assertThat(result[0], equalTo("~username")); + assertThat(result[1], equalTo("my-repo")); + } + + @Test + void parseBitbucketRemoteUrl_no_dotgit_suffix() { + String[] result = StashNotifier.parseBitbucketRemoteUrl("https://bitbucket.example.com/scm/PROJ/repo"); + assertThat(result[0], equalTo("PROJ")); + assertThat(result[1], equalTo("repo")); + } + + @Test + void parseBitbucketRemoteUrl_null() { + assertThat(StashNotifier.parseBitbucketRemoteUrl(null), nullValue()); + } + + @Test + void parseBitbucketRemoteUrl_empty() { + assertThat(StashNotifier.parseBitbucketRemoteUrl(""), nullValue()); + } }