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:
+ *
+ * - HTTPS: {@code https://host/scm/PROJECT/repo.git}
+ * - SSH: {@code ssh://git@host:7999/PROJECT/repo.git}
+ * - SCP-style: {@code git@host:PROJECT/repo.git}
+ * - Personal repos: {@code https://host/scm/~user/repo.git}
+ *
+ *
+ * @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());
+ }
}