Skip to content

Commit 0372ac8

Browse files
committed
Add Azure DevOps Server support
1 parent 8f8b103 commit 0372ac8

File tree

18 files changed

+917
-51
lines changed

18 files changed

+917
-51
lines changed

wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOps.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2012-2023 Red Hat, Inc.
2+
* Copyright (c) 2012-2025 Red Hat, Inc.
33
* This program and the accompanying materials are made
44
* available under the terms of the Eclipse Public License 2.0
55
* which is available at https://www.eclipse.org/legal/epl-2.0/
@@ -23,6 +23,8 @@
2323
public class AzureDevOps {
2424
/** Name of this OAuth provider as found in OAuthAPI. */
2525
public static final String PROVIDER_NAME = "azure-devops";
26+
/** Azure DevOps SAAS endpoint. */
27+
public static final String SAAS_ENDPOINT = "https://dev.azure.com";
2628
/** Azure DevOps Service API version calls. */
2729
public static final String API_VERSION = "7.0";
2830

wsmaster/che-core-api-factory-azure-devops/src/main/java/org/eclipse/che/api/factory/server/azure/devops/AzureDevOpsPersonalAccessTokenFetcher.java

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public class AzureDevOpsPersonalAccessTokenFetcher implements PersonalAccessToke
5151
LoggerFactory.getLogger(AzureDevOpsPersonalAccessTokenFetcher.class);
5252
private static final String OAUTH_PROVIDER_NAME = "azure-devops";
5353
private final String cheApiEndpoint;
54-
private final String azureDevOpsScmApiEndpoint;
54+
private final String azureDevOpsSAASApiEndpoint;
5555
private final OAuthAPI oAuthAPI;
5656
private final String[] scopes;
5757

@@ -60,12 +60,12 @@ public class AzureDevOpsPersonalAccessTokenFetcher implements PersonalAccessToke
6060
@Inject
6161
public AzureDevOpsPersonalAccessTokenFetcher(
6262
@Named("che.api") String cheApiEndpoint,
63-
@Named("che.integration.azure.devops.scm.api_endpoint") String azureDevOpsScmApiEndpoint,
63+
@Named("che.integration.azure.devops.scm.api_endpoint") String azureDevOpsSAASApiEndpoint,
6464
@Named("che.integration.azure.devops.application_scopes") String[] scopes,
6565
AzureDevOpsApiClient azureDevOpsApiClient,
6666
OAuthAPI oAuthAPI) {
6767
this.cheApiEndpoint = cheApiEndpoint;
68-
this.azureDevOpsScmApiEndpoint = trimEnd(azureDevOpsScmApiEndpoint, '/');
68+
this.azureDevOpsSAASApiEndpoint = trimEnd(azureDevOpsSAASApiEndpoint, '/');
6969
this.oAuthAPI = oAuthAPI;
7070
this.scopes = scopes;
7171
this.azureDevOpsApiClient = azureDevOpsApiClient;
@@ -88,7 +88,7 @@ private PersonalAccessToken fetchOrRefreshPersonalAccessToken(
8888
throws ScmUnauthorizedException, ScmCommunicationException, UnknownScmProviderException {
8989
OAuthToken oAuthToken;
9090

91-
if (!isValidScmServerUrl(scmServerUrl)) {
91+
if (!isValidAzureDevOpsSAASUrl(scmServerUrl)) {
9292
LOG.debug("not a valid url {} for current fetcher ", scmServerUrl);
9393
return null;
9494
}
@@ -147,7 +147,7 @@ private ScmUnauthorizedException buildScmUnauthorizedException(Subject cheSubjec
147147

148148
@Override
149149
public Optional<Boolean> isValid(PersonalAccessToken personalAccessToken) {
150-
if (!isValidScmServerUrl(personalAccessToken.getScmProviderUrl())) {
150+
if (!isValidAzureDevOpsSAASUrl(personalAccessToken.getScmProviderUrl())) {
151151
LOG.debug("not a valid url {} for current fetcher ", personalAccessToken.getScmProviderUrl());
152152
return Optional.empty();
153153
}
@@ -174,9 +174,20 @@ public Optional<Boolean> isValid(PersonalAccessToken personalAccessToken) {
174174
@Override
175175
public Optional<Pair<Boolean, String>> isValid(PersonalAccessTokenParams params)
176176
throws ScmCommunicationException {
177-
if (!isValidScmServerUrl(params.getScmProviderUrl())) {
178-
LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl());
179-
return Optional.empty();
177+
if (!isValidAzureDevOpsSAASUrl(params.getScmProviderUrl())) {
178+
if (OAUTH_PROVIDER_NAME.equals(params.getScmProviderName())) {
179+
AzureDevOpsServerApiClient azureDevOpsServerApiClient =
180+
new AzureDevOpsServerApiClient(params.getScmProviderUrl(), params.getOrganization());
181+
try {
182+
AzureDevOpsServerUserProfile user = azureDevOpsServerApiClient.getUser(params.getToken());
183+
return Optional.of(Pair.of(Boolean.TRUE, user.getIdentity().getAccountName()));
184+
} catch (ScmItemNotFoundException | ScmBadRequestException e) {
185+
return Optional.empty();
186+
}
187+
} else {
188+
LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl());
189+
return Optional.empty();
190+
}
180191
}
181192

182193
try {
@@ -196,7 +207,7 @@ private String getLocalAuthenticateUrl() {
196207
return cheApiEndpoint + getAuthenticateUrlPath(scopes);
197208
}
198209

199-
private Boolean isValidScmServerUrl(String scmServerUrl) {
200-
return azureDevOpsScmApiEndpoint.equals(trimEnd(scmServerUrl, '/'));
210+
private Boolean isValidAzureDevOpsSAASUrl(String url) {
211+
return azureDevOpsSAASApiEndpoint.equals(trimEnd(url, '/'));
201212
}
202213
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright (c) 2012-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package org.eclipse.che.api.factory.server.azure.devops;
13+
14+
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
15+
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
16+
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
17+
import static java.net.HttpURLConnection.HTTP_OK;
18+
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static java.time.Duration.ofSeconds;
20+
import static org.eclipse.che.commons.lang.StringUtils.trimEnd;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.google.common.base.Charsets;
24+
import com.google.common.io.CharStreams;
25+
import com.google.common.util.concurrent.ThreadFactoryBuilder;
26+
import java.io.IOException;
27+
import java.io.InputStream;
28+
import java.io.InputStreamReader;
29+
import java.io.UncheckedIOException;
30+
import java.net.URI;
31+
import java.net.http.HttpClient;
32+
import java.net.http.HttpRequest;
33+
import java.net.http.HttpResponse;
34+
import java.time.Duration;
35+
import java.util.Base64;
36+
import java.util.concurrent.Executors;
37+
import java.util.function.Function;
38+
import org.eclipse.che.api.factory.server.scm.exception.ScmBadRequestException;
39+
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
40+
import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException;
41+
import org.eclipse.che.commons.lang.concurrent.LoggingUncaughtExceptionHandler;
42+
import org.slf4j.Logger;
43+
import org.slf4j.LoggerFactory;
44+
45+
/** Azure DevOps Server API operations helper. */
46+
public class AzureDevOpsServerApiClient {
47+
48+
private static final Logger LOG = LoggerFactory.getLogger(AzureDevOpsServerApiClient.class);
49+
50+
private final HttpClient httpClient;
51+
private final String azureDevOpsServerApiEndpoint;
52+
private final String azureDevOpsServerCollection;
53+
private static final Duration DEFAULT_HTTP_TIMEOUT = ofSeconds(10);
54+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
55+
56+
public AzureDevOpsServerApiClient(
57+
String azureDevOpsServerApiEndpoint, String azureDevOpsServerCollection) {
58+
this.azureDevOpsServerApiEndpoint = trimEnd(azureDevOpsServerApiEndpoint, '/');
59+
this.azureDevOpsServerCollection = azureDevOpsServerCollection;
60+
this.httpClient =
61+
HttpClient.newBuilder()
62+
.executor(
63+
Executors.newCachedThreadPool(
64+
new ThreadFactoryBuilder()
65+
.setUncaughtExceptionHandler(LoggingUncaughtExceptionHandler.getInstance())
66+
.setNameFormat(AzureDevOpsServerApiClient.class.getName() + "-%d")
67+
.setDaemon(true)
68+
.build()))
69+
.connectTimeout(DEFAULT_HTTP_TIMEOUT)
70+
.version(HttpClient.Version.HTTP_1_1)
71+
.build();
72+
}
73+
74+
/**
75+
* Returns the user associated with the provided PAT.
76+
*
77+
* @param token personal access token.
78+
*/
79+
public AzureDevOpsServerUserProfile getUser(String token)
80+
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
81+
final String url =
82+
String.format(
83+
"%s/%s/_api/_common/GetUserProfile",
84+
azureDevOpsServerApiEndpoint, azureDevOpsServerCollection);
85+
return getUser(url, encodeAuthorizationHeader(token));
86+
}
87+
88+
private static String encodeAuthorizationHeader(String token) {
89+
return "Basic " + Base64.getEncoder().encodeToString((":" + token).getBytes(UTF_8));
90+
}
91+
92+
private AzureDevOpsServerUserProfile getUser(String url, String authorizationHeader)
93+
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
94+
final HttpRequest userDataRequest =
95+
HttpRequest.newBuilder(URI.create(url))
96+
.headers("Authorization", authorizationHeader)
97+
.timeout(DEFAULT_HTTP_TIMEOUT)
98+
.build();
99+
100+
LOG.trace("executeRequest={}", userDataRequest);
101+
return executeRequest(
102+
httpClient,
103+
userDataRequest,
104+
response -> {
105+
try {
106+
String result =
107+
CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
108+
return OBJECT_MAPPER.readValue(result, AzureDevOpsServerUserProfile.class);
109+
} catch (IOException e) {
110+
throw new UncheckedIOException(e);
111+
}
112+
});
113+
}
114+
115+
private <T> T executeRequest(
116+
HttpClient httpClient,
117+
HttpRequest request,
118+
Function<HttpResponse<InputStream>, T> responseConverter)
119+
throws ScmBadRequestException, ScmItemNotFoundException, ScmCommunicationException {
120+
try {
121+
HttpResponse<InputStream> response =
122+
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
123+
LOG.trace("executeRequest={} response {}", request, response.statusCode());
124+
if (response.statusCode() == HTTP_OK) {
125+
return responseConverter.apply(response);
126+
} else if (response.statusCode() == HTTP_NO_CONTENT) {
127+
return null;
128+
} else {
129+
String body = CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
130+
switch (response.statusCode()) {
131+
case HTTP_BAD_REQUEST:
132+
throw new ScmBadRequestException(body);
133+
case HTTP_NOT_FOUND:
134+
throw new ScmItemNotFoundException(body);
135+
default:
136+
throw new ScmCommunicationException(
137+
"Unexpected status code " + response.statusCode() + " " + response,
138+
response.statusCode(),
139+
"azure-devops");
140+
}
141+
}
142+
} catch (IOException | InterruptedException | UncheckedIOException e) {
143+
throw new ScmCommunicationException(e.getMessage(), e);
144+
}
145+
}
146+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright (c) 2012-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package org.eclipse.che.api.factory.server.azure.devops;
13+
14+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
15+
import com.fasterxml.jackson.annotation.JsonProperty;
16+
17+
/** Azure DevOps Server user's identity. */
18+
@JsonIgnoreProperties(ignoreUnknown = true)
19+
public class AzureDevOpsServerUserIdentity {
20+
private String accountName;
21+
private String mailAddress;
22+
23+
public String getAccountName() {
24+
return accountName;
25+
}
26+
27+
@JsonProperty("AccountName")
28+
public void setAccountName(String accountName) {
29+
this.accountName = accountName;
30+
}
31+
32+
public String getMailAddress() {
33+
return mailAddress;
34+
}
35+
36+
@JsonProperty("MailAddress")
37+
public void setMailAddress(String mailAddress) {
38+
this.mailAddress = mailAddress;
39+
}
40+
41+
@Override
42+
public String toString() {
43+
return "AzureDevOpsServerUserIdentity{"
44+
+ "accountName='"
45+
+ accountName
46+
+ '\''
47+
+ ", mailAddress='"
48+
+ mailAddress
49+
+ '\''
50+
+ '}';
51+
}
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright (c) 2012-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package org.eclipse.che.api.factory.server.azure.devops;
13+
14+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
15+
import com.fasterxml.jackson.annotation.JsonProperty;
16+
17+
/** Azure DevOps Server user's preferences. */
18+
@JsonIgnoreProperties(ignoreUnknown = true)
19+
public class AzureDevOpsServerUserPreferences {
20+
private String preferredEmail;
21+
22+
public String getPreferredEmail() {
23+
return preferredEmail;
24+
}
25+
26+
@JsonProperty("PreferredEmail")
27+
public void setPreferredEmail(String preferredEmail) {
28+
this.preferredEmail = preferredEmail;
29+
}
30+
31+
@Override
32+
public String toString() {
33+
return "AzureDevOpsServerUserPreferences{" + "preferredEmail='" + preferredEmail + '\'' + '}';
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2012-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package org.eclipse.che.api.factory.server.azure.devops;
13+
14+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
15+
16+
/** Azure DevOps Server user's profile. */
17+
@JsonIgnoreProperties(ignoreUnknown = true)
18+
public class AzureDevOpsServerUserProfile {
19+
private AzureDevOpsServerUserIdentity identity;
20+
private AzureDevOpsServerUserPreferences userPreferences;
21+
private String defaultMailAddress;
22+
23+
public AzureDevOpsServerUserIdentity getIdentity() {
24+
return identity;
25+
}
26+
27+
public void setIdentity(AzureDevOpsServerUserIdentity identity) {
28+
this.identity = identity;
29+
}
30+
31+
public String getDefaultMailAddress() {
32+
return defaultMailAddress;
33+
}
34+
35+
public void setDefaultMailAddress(String defaultMailAddress) {
36+
this.defaultMailAddress = defaultMailAddress;
37+
}
38+
39+
public AzureDevOpsServerUserPreferences getUserPreferences() {
40+
return userPreferences;
41+
}
42+
43+
public void setUserPreferences(AzureDevOpsServerUserPreferences userPreferences) {
44+
this.userPreferences = userPreferences;
45+
}
46+
47+
@Override
48+
public String toString() {
49+
return "AzureDevOpsServerUserProfile{"
50+
+ "identity="
51+
+ identity
52+
+ ", userPreferences="
53+
+ userPreferences
54+
+ ", defaultMailAddress='"
55+
+ defaultMailAddress
56+
+ '\''
57+
+ '}';
58+
}
59+
}

0 commit comments

Comments
 (0)