• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Jericho / ZoomNet / 626

29 Jul 2023 09:03PM UTC coverage: 17.563% (-0.03%) from 17.594%
626

push

appveyor

Jericho
Merge branch 'release/0.66.0'

506 of 2881 relevant lines covered (17.56%)

2.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

26.79
/Source/ZoomNet/OAuthConnectionInfo.cs
1
using System;
2
using System.Collections.Generic;
3
using ZoomNet.Models;
4

5
namespace ZoomNet
6
{
7
        /// <summary>
8
        /// The delegate invoked when a token is refreshed.
9
        /// </summary>
10
        /// <param name="newRefreshToken">The new refresh token.</param>
11
        /// <param name="newAccessToken">The new access token.</param>
12
        public delegate void OnTokenRefreshedDelegate(string newRefreshToken, string newAccessToken);
13

14
        /// <summary>
15
        /// Connect using OAuth.
16
        /// </summary>
17
        public class OAuthConnectionInfo : IConnectionInfo
18
        {
19
                /// <summary>
20
                /// Gets the account id.
21
                /// </summary>
22
                /// <remarks>This is relevant only when using Server-to-Server authentication.</remarks>
23
                public string AccountId { get; private set; }
24

25
                /// <summary>
26
                /// Gets the client id.
27
                /// </summary>
28
                public string ClientId { get; private set; }
29

30
                /// <summary>
31
                /// Gets the client secret.
32
                /// </summary>
33
                public string ClientSecret { get; private set; }
34

35
                /// <summary>
36
                /// Gets the grant type.
37
                /// </summary>
38
                public OAuthGrantType GrantType { get; internal set; }
39

40
                /// <summary>
41
                /// Gets the authorization code.
42
                /// </summary>
43
                public string AuthorizationCode { get; private set; }
44

45
                /// <summary>
46
                /// Gets the refresh token.
47
                /// </summary>
48
                public string RefreshToken { get; internal set; }
49

50
                /// <summary>
51
                /// Gets the access token.
52
                /// </summary>
53
                public string AccessToken { get; internal set; }
54

55
                /// <summary>
56
                /// Gets the token scope.
57
                /// </summary>
58
                public IReadOnlyDictionary<string, string[]> TokenScope { get; internal set; }
59

60
                /// <summary>
61
                /// Gets the token expiration time.
62
                /// </summary>
63
                public DateTime TokenExpiration { get; internal set; }
64

65
                /// <summary>
66
                /// Gets the delegate invoked when the token is refreshed.
67
                /// </summary>
68
                public OnTokenRefreshedDelegate OnTokenRefreshed { get; private set; }
69

70
                /// <summary>
71
                /// Gets the redirectUri required for refresh of tokens.
72
                /// </summary>
73
                public string RedirectUri { get; private set; }
74

75
                /// <summary>
76
                /// Gets the cryptographically random string used to correlate the authorization request to the token request.
77
                /// </summary>
78
                public string CodeVerifier { get; private set; }
79

80
                /// <summary>
81
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
82
                /// </summary>
83
                /// <remarks>
84
                /// This constructor is used to get access token for APIs that do not
85
                /// need a user's permission, but rather a service's permission.
86
                /// Within the realm of Zoom APIs, Client Credentials grant should be
87
                /// used to get access token from the Chatbot Service in order to use
88
                /// the "Send Chatbot Messages API". See the "Using OAuth 2.0 / Client
89
                /// Credentials" section in the "Using Zoom APIs" document for more details
90
                /// (https://marketplace.zoom.us/docs/api-reference/using-zoom-apis).
91
                /// </remarks>
92
                /// <param name="clientId">Your Client Id.</param>
93
                /// <param name="clientSecret">Your Client Secret.</param>
94
                [Obsolete("This constructor has been replaced with OAuthConnectionInfo.WithClientCredentials")]
95
                public OAuthConnectionInfo(string clientId, string clientSecret)
96
                {
97
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
98
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
99

100
                        ClientId = clientId;
101
                        ClientSecret = clientSecret;
102
                        GrantType = OAuthGrantType.ClientCredentials;
103
                }
104

105
                /// <summary>
106
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
107
                /// </summary>
108
                /// <remarks>
109
                /// The authorization code is generated by Zoom when the user authorizes the app.
110
                /// This code can be used only one time to get the initial access token and refresh token.
111
                /// Once the authorization code has been used, is is no longer valid and should be discarded.
112
                ///
113
                /// Also, Zoom's documentation says that the redirect uri must be provided when validating an
114
                /// authorization code and converting it into tokens. However I have observed that it's not
115
                /// always necessary. It seems that some developers get a "REDIRECT URI MISMATCH" exception when
116
                /// they omit this value but other developers don't. Therefore, the redirectUri parameter is
117
                /// marked as optional in ZoomNet which allows you to specify it or omit it depending on your
118
                /// situation. See this <a href="https://github.com/Jericho/ZoomNet/issues/104">Github issue</a>
119
                /// and this <a href="https://devforum.zoom.us/t/trying-to-integrate-not-understanding-the-need-for-the-second-redirect-uri/43833">support thread</a>
120
                /// for more details.
121
                /// </remarks>
122
                /// <param name="clientId">Your Client Id.</param>
123
                /// <param name="clientSecret">Your Client Secret.</param>
124
                /// <param name="authorizationCode">The authorization code.</param>
125
                /// <param name="onTokenRefreshed">The delegate invoked when the token is refreshed.</param>
126
                /// <param name="redirectUri">The Redirect Uri.</param>
127
                /// <param name="codeVerifier">The cryptographically random string used to correlate the authorization request to the token request.</param>
128
                [Obsolete("This constructor has been replaced with OAuthConnectionInfo.WithAuthorizationCode")]
129
                public OAuthConnectionInfo(string clientId, string clientSecret, string authorizationCode, OnTokenRefreshedDelegate onTokenRefreshed, string redirectUri = null, string codeVerifier = null)
130
                {
131
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
132
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
133
                        if (string.IsNullOrEmpty(authorizationCode)) throw new ArgumentNullException(nameof(authorizationCode));
134

135
                        ClientId = clientId;
136
                        ClientSecret = clientSecret;
137
                        AuthorizationCode = authorizationCode;
138
                        RedirectUri = redirectUri;
139
                        TokenExpiration = DateTime.MinValue;
140
                        GrantType = OAuthGrantType.AuthorizationCode;
141
                        OnTokenRefreshed = onTokenRefreshed;
142
                        CodeVerifier = codeVerifier;
143
                }
144

145
                /// <summary>
146
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
147
                /// </summary>
148
                /// <remarks>
149
                /// This is the most commonly used grant type for Zoom APIs.
150
                ///
151
                /// Please note that the 'accessToken' parameter is optional.
152
                /// In fact, we recommend that you specify a null value which
153
                /// will cause ZoomNet to automatically obtain a new access
154
                /// token from the Zoom API. The reason we recommend you omit
155
                /// this parameter is that access tokens are ephemeral (they
156
                /// expire in 60 minutes) and even if you specify a token that
157
                /// was previously issued to you and that you preserved, this
158
                /// token is very likely to be expired and therefore useless.
159
                /// </remarks>
160
                /// <param name="clientId">Your Client Id.</param>
161
                /// <param name="clientSecret">Your Client Secret.</param>
162
                /// <param name="refreshToken">The refresh token.</param>
163
                /// <param name="accessToken">(Optional) The access token. We recommend you specify a null value. See remarks for more details.</param>
164
                /// <param name="onTokenRefreshed">The delegate invoked when the token is refreshed.</param>
165
                [Obsolete("This constructor has been replaced with OAuthConnectionInfo.WithRefreshToken")]
166
                public OAuthConnectionInfo(string clientId, string clientSecret, string refreshToken, string accessToken, OnTokenRefreshedDelegate onTokenRefreshed)
167
                {
168
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
169
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
170
                        if (string.IsNullOrEmpty(refreshToken)) throw new ArgumentNullException(nameof(refreshToken));
171

172
                        ClientId = clientId;
173
                        ClientSecret = clientSecret;
174
                        RefreshToken = refreshToken;
175
                        AccessToken = accessToken;
176
                        TokenExpiration = string.IsNullOrEmpty(accessToken) ? DateTime.MinValue : DateTime.MaxValue; // Set expiration to DateTime.MaxValue when an access token is provided because we don't know when it will expire
177
                        GrantType = OAuthGrantType.RefreshToken;
178
                        OnTokenRefreshed = onTokenRefreshed;
179
                }
180

181
                /// <summary>
182
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
183
                /// </summary>
184
                /// <remarks>
185
                /// Use this constructor when you want to use Server-to-Server OAuth authentication.
186
                /// </remarks>
187
                /// <param name="clientId">Your Client Id.</param>
188
                /// <param name="clientSecret">Your Client Secret.</param>
189
                /// <param name="accountId">Your Account Id.</param>
190
                /// <param name="onTokenRefreshed">The delegate invoked when the token is refreshed. In the Server-to-Server scenario, this delegate is optional.</param>
191
                [Obsolete("This constructor has been replaced with OAuthConnectionInfo.ForServerToServer")]
192
                public OAuthConnectionInfo(string clientId, string clientSecret, string accountId, OnTokenRefreshedDelegate onTokenRefreshed)
193
                {
194
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
195
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
196
                        if (string.IsNullOrEmpty(accountId)) throw new ArgumentNullException(nameof(accountId));
197

198
                        ClientId = clientId;
199
                        ClientSecret = clientSecret;
200
                        AccountId = accountId;
201
                        TokenExpiration = DateTime.MinValue;
202
                        GrantType = OAuthGrantType.AccountCredentials;
203
                        OnTokenRefreshed = onTokenRefreshed;
204
                }
205

206
                private OAuthConnectionInfo() { }
1✔
207

208
                /// <summary>
209
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
210
                /// </summary>
211
                /// <remarks>
212
                /// This constructor is used to get access token for APIs that do not
213
                /// need a user's permission, but rather a service's permission.
214
                /// Within the realm of Zoom APIs, Client Credentials grant should be
215
                /// used to get access token from the Chatbot Service in order to use
216
                /// the "Send Chatbot Messages API". See the "Using OAuth 2.0 / Client
217
                /// Credentials" section in the "Using Zoom APIs" document for more details
218
                /// (https://marketplace.zoom.us/docs/api-reference/using-zoom-apis).
219
                /// </remarks>
220
                /// <param name="clientId">Your Client Id.</param>
221
                /// <param name="clientSecret">Your Client Secret.</param>
222
                /// <returns>The connection info.</returns>
223
                public static OAuthConnectionInfo WithClientCredentials(string clientId, string clientSecret)
224
                {
225
                        return WithClientCredentials(clientId, clientSecret, null, null);
×
226
                }
227

228
                /// <summary>
229
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
230
                /// </summary>
231
                /// <remarks>
232
                /// This constructor is used to get access token for APIs that do not
233
                /// need a user's permission, but rather a service's permission.
234
                /// Within the realm of Zoom APIs, Client Credentials grant should be
235
                /// used to get access token from the Chatbot Service in order to use
236
                /// the "Send Chatbot Messages API". See the "Using OAuth 2.0 / Client
237
                /// Credentials" section in the "Using Zoom APIs" document for more details
238
                /// (https://marketplace.zoom.us/docs/api-reference/using-zoom-apis).
239
                /// </remarks>
240
                /// <param name="clientId">Your Client Id.</param>
241
                /// <param name="clientSecret">Your Client Secret.</param>
242
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued.</param>
243
                /// <returns>The connection info.</returns>
244
                public static OAuthConnectionInfo WithClientCredentials(string clientId, string clientSecret, OnTokenRefreshedDelegate onTokenRefreshed)
245
                {
246
                        return WithClientCredentials(clientId, clientSecret, null, onTokenRefreshed);
×
247
                }
248

249
                /// <summary>
250
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
251
                /// </summary>
252
                /// <remarks>
253
                /// This constructor is used to get access token for APIs that do not
254
                /// need a user's permission, but rather a service's permission.
255
                /// Within the realm of Zoom APIs, Client Credentials grant should be
256
                /// used to get access token from the Chatbot Service in order to use
257
                /// the "Send Chatbot Messages API". See the "Using OAuth 2.0 / Client
258
                /// Credentials" section in the "Using Zoom APIs" document for more details
259
                /// (https://marketplace.zoom.us/docs/api-reference/using-zoom-apis).
260
                /// </remarks>
261
                /// <param name="clientId">Your Client Id.</param>
262
                /// <param name="clientSecret">Your Client Secret.</param>
263
                /// <param name="accessToken">The access token.</param>
264
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued.</param>
265
                /// <returns>The connection info.</returns>
266
                public static OAuthConnectionInfo WithClientCredentials(string clientId, string clientSecret, string accessToken, OnTokenRefreshedDelegate onTokenRefreshed)
267
                {
268
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
×
269
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
×
270

271
                        return new OAuthConnectionInfo
×
272
                        {
×
273
                                ClientId = clientId,
×
274
                                ClientSecret = clientSecret,
×
275
                                AccessToken = accessToken,
×
276
                                TokenExpiration = string.IsNullOrEmpty(accessToken) ? DateTime.MinValue : DateTime.MaxValue, // Set expiration to DateTime.MaxValue when an access token is provided because we don't know when it will expire
×
277
                                GrantType = OAuthGrantType.ClientCredentials,
×
278
                                OnTokenRefreshed = onTokenRefreshed,
×
279
                        };
×
280
                }
281

282
                /// <summary>
283
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
284
                /// </summary>
285
                /// <remarks>
286
                /// The authorization code is generated by Zoom when the user authorizes the app.
287
                /// This code can be used only one time to get the initial access token and refresh token.
288
                /// Once the authorization code has been used, is is no longer valid and should be discarded.
289
                ///
290
                /// Also, Zoom's documentation says that the redirect uri must be provided when validating an
291
                /// authorization code and converting it into tokens. However I have observed that it's not
292
                /// always necessary. It seems that some developers get a "REDIRECT URI MISMATCH" exception when
293
                /// they omit this value but other developers don't. Therefore, the redirectUri parameter is
294
                /// marked as optional in ZoomNet which allows you to specify it or omit it depending on your
295
                /// situation. See this <a href="https://github.com/Jericho/ZoomNet/issues/104">Github issue</a>
296
                /// and this <a href="https://devforum.zoom.us/t/trying-to-integrate-not-understanding-the-need-for-the-second-redirect-uri/43833">support thread</a>
297
                /// for more details.
298
                /// </remarks>
299
                /// <param name="clientId">Your Client Id.</param>
300
                /// <param name="clientSecret">Your Client Secret.</param>
301
                /// <param name="authorizationCode">The authorization code.</param>
302
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued.</param>
303
                /// <param name="redirectUri">The Redirect Uri.</param>
304
                /// <param name="codeVerifier">The cryptographically random string used to correlate the authorization request to the token request.</param>
305
                /// <returns>The connection info.</returns>
306
                public static OAuthConnectionInfo WithAuthorizationCode(string clientId, string clientSecret, string authorizationCode, OnTokenRefreshedDelegate onTokenRefreshed, string redirectUri = null, string codeVerifier = null)
307
                {
308
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
1✔
309
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
1✔
310
                        if (string.IsNullOrEmpty(authorizationCode)) throw new ArgumentNullException(nameof(authorizationCode));
1✔
311

312
                        return new OAuthConnectionInfo
1✔
313
                        {
1✔
314
                                ClientId = clientId,
1✔
315
                                ClientSecret = clientSecret,
1✔
316
                                AuthorizationCode = authorizationCode,
1✔
317
                                RedirectUri = redirectUri,
1✔
318
                                TokenExpiration = DateTime.MinValue,
1✔
319
                                GrantType = OAuthGrantType.AuthorizationCode,
1✔
320
                                OnTokenRefreshed = onTokenRefreshed,
1✔
321
                                CodeVerifier = codeVerifier,
1✔
322
                        };
1✔
323
                }
324

325
                /// <summary>
326
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
327
                /// </summary>
328
                /// <remarks>
329
                /// This is the most commonly used grant type for Zoom APIs.
330
                /// </remarks>
331
                /// <param name="clientId">Your Client Id.</param>
332
                /// <param name="clientSecret">Your Client Secret.</param>
333
                /// <param name="refreshToken">The refresh token.</param>
334
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued or refreshed.</param>
335
                /// <returns>The connection info.</returns>
336
                public static OAuthConnectionInfo WithRefreshToken(string clientId, string clientSecret, string refreshToken, OnTokenRefreshedDelegate onTokenRefreshed)
337
                {
338
                        return WithRefreshToken(clientId, clientSecret, refreshToken, null, onTokenRefreshed);
×
339
                }
340

341
                /// <summary>
342
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
343
                /// </summary>
344
                /// <remarks>
345
                /// This is the most commonly used grant type for Zoom APIs.
346
                ///
347
                /// Please note that the 'accessToken' parameter is optional.
348
                /// In fact, we recommend that you specify a null value which
349
                /// will cause ZoomNet to automatically obtain a new access
350
                /// token from the Zoom API. The reason we recommend you omit
351
                /// this parameter is that access tokens are ephemeral (they
352
                /// expire in 60 minutes) and even if you specify a token that
353
                /// was previously issued to you and that you preserved, this
354
                /// token is very likely to be expired and therefore useless.
355
                /// </remarks>
356
                /// <param name="clientId">Your Client Id.</param>
357
                /// <param name="clientSecret">Your Client Secret.</param>
358
                /// <param name="refreshToken">The refresh token.</param>
359
                /// <param name="accessToken">(Optional) The access token. We recommend you specify a null value. See remarks for more details.</param>
360
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued or refreshed.</param>
361
                /// <returns>The connection info.</returns>
362
                public static OAuthConnectionInfo WithRefreshToken(string clientId, string clientSecret, string refreshToken, string accessToken, OnTokenRefreshedDelegate onTokenRefreshed)
363
                {
364
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
×
365
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
×
366
                        if (string.IsNullOrEmpty(refreshToken)) throw new ArgumentNullException(nameof(refreshToken));
×
367

368
                        return new OAuthConnectionInfo
×
369
                        {
×
370
                                ClientId = clientId,
×
371
                                ClientSecret = clientSecret,
×
372
                                RefreshToken = refreshToken,
×
373
                                AccessToken = accessToken,
×
374
                                TokenExpiration = string.IsNullOrEmpty(accessToken) ? DateTime.MinValue : DateTime.MaxValue, // Set expiration to DateTime.MaxValue when an access token is provided because we don't know when it will expire
×
375
                                GrantType = OAuthGrantType.RefreshToken,
×
376
                                OnTokenRefreshed = onTokenRefreshed,
×
377
                        };
×
378
                }
379

380
                /// <summary>
381
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
382
                /// </summary>
383
                /// <remarks>
384
                /// Use this constructor when you want to use Server-to-Server OAuth authentication.
385
                /// </remarks>
386
                /// <param name="clientId">Your Client Id.</param>
387
                /// <param name="clientSecret">Your Client Secret.</param>
388
                /// <param name="accountId">Your Account Id.</param>
389
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued. In the Server-to-Server scenario, this delegate is optional.</param>
390
                /// <returns>The connection info.</returns>
391
                public static OAuthConnectionInfo ForServerToServer(string clientId, string clientSecret, string accountId, OnTokenRefreshedDelegate onTokenRefreshed = null)
392
                {
393
                        return ForServerToServer(clientId, clientSecret, accountId, null, onTokenRefreshed);
×
394
                }
395

396
                /// <summary>
397
                /// Initializes a new instance of the <see cref="OAuthConnectionInfo"/> class.
398
                /// </summary>
399
                /// <remarks>
400
                /// Use this constructor when you want to use Server-to-Server OAuth authentication.
401
                ///
402
                /// Please note that the 'accessToken' parameter is optional.
403
                /// In fact, we recommend that you specify a null value which
404
                /// will cause ZoomNet to automatically obtain a new access
405
                /// token from the Zoom API. The reason we recommend you omit
406
                /// this parameter is that access tokens are ephemeral (they
407
                /// expire in 60 minutes) and even if you specify a token that
408
                /// was previously issued to you and that you preserved, this
409
                /// token is very likely to be expired and therefore useless.
410
                /// </remarks>
411
                /// <param name="clientId">Your Client Id.</param>
412
                /// <param name="clientSecret">Your Client Secret.</param>
413
                /// <param name="accountId">Your Account Id.</param>
414
                /// <param name="accessToken">(Optional) The access token. We recommend you specify a null value. See remarks for more details.</param>
415
                /// <param name="onTokenRefreshed">The delegate invoked when a token is issued. In the Server-to-Server scenario, this delegate is optional.</param>
416
                /// <returns>The connection info.</returns>
417
                public static OAuthConnectionInfo ForServerToServer(string clientId, string clientSecret, string accountId, string accessToken, OnTokenRefreshedDelegate onTokenRefreshed = null)
418
                {
419
                        if (string.IsNullOrEmpty(clientId)) throw new ArgumentNullException(nameof(clientId));
×
420
                        if (string.IsNullOrEmpty(clientSecret)) throw new ArgumentNullException(nameof(clientSecret));
×
421
                        if (string.IsNullOrEmpty(accountId)) throw new ArgumentNullException(nameof(accountId));
×
422

423
                        return new OAuthConnectionInfo
×
424
                        {
×
425
                                ClientId = clientId,
×
426
                                ClientSecret = clientSecret,
×
427
                                AccountId = accountId,
×
428
                                AccessToken = accessToken,
×
429
                                TokenExpiration = string.IsNullOrEmpty(accessToken) ? DateTime.MinValue : DateTime.MaxValue, // Set expiration to DateTime.MaxValue when an access token is provided because we don't know when it will expire
×
430
                                GrantType = OAuthGrantType.AccountCredentials,
×
431
                                OnTokenRefreshed = onTokenRefreshed,
×
432
                        };
×
433
                }
434
        }
435
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc