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

mybatis / spring / #1512

28 May 2024 08:25PM CUT coverage: 90.232%. Remained the same
#1512

Pull #957

github

web-flow
Update dependency org.mybatis:mybatis-parent to v44
Pull Request #957: Update dependency org.mybatis:mybatis-parent to v44

309 of 370 branches covered (83.51%)

933 of 1034 relevant lines covered (90.23%)

0.9 hits per line

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

92.48
/src/main/java/org/mybatis/spring/mapper/MapperScannerConfigurer.java
1
/*
2
 * Copyright 2010-2024 the original author or authors.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *    https://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
package org.mybatis.spring.mapper;
17

18
import static org.springframework.util.Assert.notNull;
19

20
import java.lang.annotation.Annotation;
21
import java.util.ArrayList;
22
import java.util.List;
23
import java.util.Map;
24
import java.util.Optional;
25
import java.util.regex.Pattern;
26

27
import org.apache.ibatis.session.SqlSessionFactory;
28
import org.mybatis.spring.SqlSessionTemplate;
29
import org.springframework.beans.BeanUtils;
30
import org.springframework.beans.PropertyValue;
31
import org.springframework.beans.PropertyValues;
32
import org.springframework.beans.factory.BeanNameAware;
33
import org.springframework.beans.factory.InitializingBean;
34
import org.springframework.beans.factory.config.BeanDefinition;
35
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
36
import org.springframework.beans.factory.config.PropertyResourceConfigurer;
37
import org.springframework.beans.factory.config.TypedStringValue;
38
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
39
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
40
import org.springframework.beans.factory.support.BeanNameGenerator;
41
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
42
import org.springframework.context.ApplicationContext;
43
import org.springframework.context.ApplicationContextAware;
44
import org.springframework.context.ConfigurableApplicationContext;
45
import org.springframework.core.env.Environment;
46
import org.springframework.core.type.filter.AnnotationTypeFilter;
47
import org.springframework.core.type.filter.AspectJTypeFilter;
48
import org.springframework.core.type.filter.AssignableTypeFilter;
49
import org.springframework.core.type.filter.RegexPatternTypeFilter;
50
import org.springframework.core.type.filter.TypeFilter;
51
import org.springframework.lang.Nullable;
52
import org.springframework.util.ClassUtils;
53
import org.springframework.util.StringUtils;
54

55
/**
56
 * BeanDefinitionRegistryPostProcessor that searches recursively starting from a base package for interfaces and
57
 * registers them as {@code MapperFactoryBean}. Note that only interfaces with at least one method will be registered;
58
 * concrete classes will be ignored.
59
 * <p>
60
 * This class was a {code BeanFactoryPostProcessor} until 1.0.1 version. It changed to
61
 * {@code BeanDefinitionRegistryPostProcessor} in 1.0.2. See https://jira.springsource.org/browse/SPR-8269 for the
62
 * details.
63
 * <p>
64
 * The {@code basePackage} property can contain more than one package name, separated by either commas or semicolons.
65
 * <p>
66
 * This class supports filtering the mappers created by either specifying a marker interface or an annotation. The
67
 * {@code annotationClass} property specifies an annotation to search for. The {@code markerInterface} property
68
 * specifies a parent interface to search for. If both properties are specified, mappers are added for interfaces that
69
 * match <em>either</em> criteria. By default, these two properties are null, so all interfaces in the given
70
 * {@code basePackage} are added as mappers.
71
 * <p>
72
 * This configurer enables autowire for all the beans that it creates so that they are automatically autowired with the
73
 * proper {@code SqlSessionFactory} or {@code SqlSessionTemplate}. If there is more than one {@code SqlSessionFactory}
74
 * in the application, however, autowiring cannot be used. In this case you must explicitly specify either an
75
 * {@code SqlSessionFactory} or an {@code SqlSessionTemplate} to use via the <em>bean name</em> properties. Bean names
76
 * are used rather than actual objects because Spring does not initialize property placeholders until after this class
77
 * is processed.
78
 * <p>
79
 * Passing in an actual object which may require placeholders (i.e. DB user password) will fail. Using bean names defers
80
 * actual object creation until later in the startup process, after all placeholder substitution is completed. However,
81
 * note that this configurer does support property placeholders of its <em>own</em> properties. The
82
 * <code>basePackage</code> and bean name properties all support <code>${property}</code> style substitution.
83
 * <p>
84
 * Configuration sample:
85
 *
86
 * <pre class="code">
87
 * {@code
88
 *   <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
89
 *       <property name="basePackage" value="org.mybatis.spring.sample.mapper" />
90
 *       <!-- optional unless there are multiple session factories defined -->
91
 *       <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory" />
92
 *   </bean>
93
 * }
94
 * </pre>
95
 *
96
 * @author Hunter Presnall
97
 * @author Eduardo Macarron
98
 *
99
 * @see MapperFactoryBean
100
 * @see ClassPathMapperScanner
101
 */
102
public class MapperScannerConfigurer
1✔
103
    implements BeanDefinitionRegistryPostProcessor, InitializingBean, ApplicationContextAware, BeanNameAware {
104

105
  private String basePackage;
106

107
  private boolean addToConfig = true;
1✔
108

109
  private String lazyInitialization;
110

111
  private SqlSessionFactory sqlSessionFactory;
112

113
  private SqlSessionTemplate sqlSessionTemplate;
114

115
  private String sqlSessionFactoryBeanName;
116

117
  private String sqlSessionTemplateBeanName;
118

119
  private Class<? extends Annotation> annotationClass;
120

121
  private Class<?> markerInterface;
122

123
  private List<TypeFilter> excludeFilters;
124

125
  private List<Map<String, String>> rawExcludeFilters;
126

127
  private Class<? extends MapperFactoryBean> mapperFactoryBeanClass;
128

129
  private ApplicationContext applicationContext;
130

131
  private String beanName;
132

133
  private boolean processPropertyPlaceHolders;
134

135
  private BeanNameGenerator nameGenerator;
136

137
  private String defaultScope;
138

139
  /**
140
   * This property lets you set the base package for your mapper interface files.
141
   * <p>
142
   * You can set more than one package by using a semicolon or comma as a separator.
143
   * <p>
144
   * Mappers will be searched for recursively starting in the specified package(s).
145
   *
146
   * @param basePackage
147
   *          base package name
148
   */
149
  public void setBasePackage(String basePackage) {
150
    this.basePackage = basePackage;
1✔
151
  }
1✔
152

153
  /**
154
   * Same as {@code MapperFactoryBean#setAddToConfig(boolean)}.
155
   *
156
   * @param addToConfig
157
   *          a flag that whether add mapper to MyBatis or not
158
   *
159
   * @see MapperFactoryBean#setAddToConfig(boolean)
160
   */
161
  public void setAddToConfig(boolean addToConfig) {
162
    this.addToConfig = addToConfig;
×
163
  }
×
164

165
  /**
166
   * Set whether enable lazy initialization for mapper bean.
167
   * <p>
168
   * Default is {@code false}.
169
   * </p>
170
   *
171
   * @param lazyInitialization
172
   *          Set the @{code true} to enable
173
   *
174
   * @since 2.0.2
175
   */
176
  public void setLazyInitialization(String lazyInitialization) {
177
    this.lazyInitialization = lazyInitialization;
1✔
178
  }
1✔
179

180
  /**
181
   * This property specifies the annotation that the scanner will search for.
182
   * <p>
183
   * The scanner will register all interfaces in the base package that also have the specified annotation.
184
   * <p>
185
   * Note this can be combined with markerInterface.
186
   *
187
   * @param annotationClass
188
   *          annotation class
189
   */
190
  public void setAnnotationClass(Class<? extends Annotation> annotationClass) {
191
    this.annotationClass = annotationClass;
1✔
192
  }
1✔
193

194
  /**
195
   * This property specifies the parent that the scanner will search for.
196
   * <p>
197
   * The scanner will register all interfaces in the base package that also have the specified interface class as a
198
   * parent.
199
   * <p>
200
   * Note this can be combined with annotationClass.
201
   *
202
   * @param superClass
203
   *          parent class
204
   */
205
  public void setMarkerInterface(Class<?> superClass) {
206
    this.markerInterface = superClass;
1✔
207
  }
1✔
208

209
  /**
210
   * Specifies which types are not eligible for the mapper scanner.
211
   * <p>
212
   * The scanner will exclude types that define with excludeFilters.
213
   *
214
   * @since 3.0.3
215
   *
216
   * @param excludeFilters
217
   *          list of TypeFilter
218
   */
219
  public void setExcludeFilters(List<TypeFilter> excludeFilters) {
220
    this.excludeFilters = excludeFilters;
1✔
221
  }
1✔
222

223
  /**
224
   * In order to support process PropertyPlaceHolders.
225
   * <p>
226
   * After parsed, it will be added to excludeFilters.
227
   *
228
   * @since 3.0.3
229
   *
230
   * @param rawExcludeFilters
231
   *          list of rawExcludeFilter
232
   */
233
  public void setRawExcludeFilters(List<Map<String, String>> rawExcludeFilters) {
234
    this.rawExcludeFilters = rawExcludeFilters;
1✔
235
  }
1✔
236

237
  /**
238
   * Specifies which {@code SqlSessionTemplate} to use in the case that there is more than one in the spring context.
239
   * Usually this is only needed when you have more than one datasource.
240
   * <p>
241
   *
242
   * @deprecated Use {@link #setSqlSessionTemplateBeanName(String)} instead
243
   *
244
   * @param sqlSessionTemplate
245
   *          a template of SqlSession
246
   */
247
  @Deprecated
248
  public void setSqlSessionTemplate(SqlSessionTemplate sqlSessionTemplate) {
249
    this.sqlSessionTemplate = sqlSessionTemplate;
×
250
  }
×
251

252
  /**
253
   * Specifies which {@code SqlSessionTemplate} to use in the case that there is more than one in the spring context.
254
   * Usually this is only needed when you have more than one datasource.
255
   * <p>
256
   * Note bean names are used, not bean references. This is because the scanner loads early during the start process and
257
   * it is too early to build mybatis object instances.
258
   *
259
   * @since 1.1.0
260
   *
261
   * @param sqlSessionTemplateName
262
   *          Bean name of the {@code SqlSessionTemplate}
263
   */
264
  public void setSqlSessionTemplateBeanName(String sqlSessionTemplateName) {
265
    this.sqlSessionTemplateBeanName = sqlSessionTemplateName;
1✔
266
  }
1✔
267

268
  /**
269
   * Specifies which {@code SqlSessionFactory} to use in the case that there is more than one in the spring context.
270
   * Usually this is only needed when you have more than one datasource.
271
   * <p>
272
   *
273
   * @deprecated Use {@link #setSqlSessionFactoryBeanName(String)} instead.
274
   *
275
   * @param sqlSessionFactory
276
   *          a factory of SqlSession
277
   */
278
  @Deprecated
279
  public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
280
    this.sqlSessionFactory = sqlSessionFactory;
×
281
  }
×
282

283
  /**
284
   * Specifies which {@code SqlSessionFactory} to use in the case that there is more than one in the spring context.
285
   * Usually this is only needed when you have more than one datasource.
286
   * <p>
287
   * Note bean names are used, not bean references. This is because the scanner loads early during the start process and
288
   * it is too early to build mybatis object instances.
289
   *
290
   * @since 1.1.0
291
   *
292
   * @param sqlSessionFactoryName
293
   *          Bean name of the {@code SqlSessionFactory}
294
   */
295
  public void setSqlSessionFactoryBeanName(String sqlSessionFactoryName) {
296
    this.sqlSessionFactoryBeanName = sqlSessionFactoryName;
1✔
297
  }
1✔
298

299
  /**
300
   * Specifies a flag that whether execute a property placeholder processing or not.
301
   * <p>
302
   * The default is {@literal false}. This means that a property placeholder processing does not execute.
303
   *
304
   * @since 1.1.1
305
   *
306
   * @param processPropertyPlaceHolders
307
   *          a flag that whether execute a property placeholder processing or not
308
   */
309
  public void setProcessPropertyPlaceHolders(boolean processPropertyPlaceHolders) {
310
    this.processPropertyPlaceHolders = processPropertyPlaceHolders;
1✔
311
  }
1✔
312

313
  /**
314
   * The class of the {@link MapperFactoryBean} to return a mybatis proxy as spring bean.
315
   *
316
   * @param mapperFactoryBeanClass
317
   *          The class of the MapperFactoryBean
318
   *
319
   * @since 2.0.1
320
   */
321
  public void setMapperFactoryBeanClass(Class<? extends MapperFactoryBean> mapperFactoryBeanClass) {
322
    this.mapperFactoryBeanClass = mapperFactoryBeanClass;
1✔
323
  }
1✔
324

325
  /**
326
   * {@inheritDoc}
327
   */
328
  @Override
329
  public void setApplicationContext(ApplicationContext applicationContext) {
330
    this.applicationContext = applicationContext;
1✔
331
  }
1✔
332

333
  /**
334
   * {@inheritDoc}
335
   */
336
  @Override
337
  public void setBeanName(String name) {
338
    this.beanName = name;
1✔
339
  }
1✔
340

341
  /**
342
   * Gets beanNameGenerator to be used while running the scanner.
343
   *
344
   * @return the beanNameGenerator BeanNameGenerator that has been configured
345
   *
346
   * @since 1.2.0
347
   */
348
  public BeanNameGenerator getNameGenerator() {
349
    return nameGenerator;
×
350
  }
351

352
  /**
353
   * Sets beanNameGenerator to be used while running the scanner.
354
   *
355
   * @param nameGenerator
356
   *          the beanNameGenerator to set
357
   *
358
   * @since 1.2.0
359
   */
360
  public void setNameGenerator(BeanNameGenerator nameGenerator) {
361
    this.nameGenerator = nameGenerator;
1✔
362
  }
1✔
363

364
  /**
365
   * Sets the default scope of scanned mappers.
366
   * <p>
367
   * Default is {@code null} (equiv to singleton).
368
   * </p>
369
   *
370
   * @param defaultScope
371
   *          the default scope
372
   *
373
   * @since 2.0.6
374
   */
375
  public void setDefaultScope(String defaultScope) {
376
    this.defaultScope = defaultScope;
1✔
377
  }
1✔
378

379
  /**
380
   * {@inheritDoc}
381
   */
382
  @Override
383
  public void afterPropertiesSet() throws Exception {
384
    notNull(this.basePackage, "Property 'basePackage' is required");
1✔
385
  }
1✔
386

387
  /**
388
   * {@inheritDoc}
389
   */
390
  @Override
391
  public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
392
    // left intentionally blank
393
  }
1✔
394

395
  /**
396
   * {@inheritDoc}
397
   *
398
   * @since 1.0.2
399
   */
400
  @Override
401
  public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
402
    if (this.processPropertyPlaceHolders) {
1✔
403
      processPropertyPlaceHolders();
1✔
404
    }
405

406
    ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry, getEnvironment());
1✔
407
    scanner.setAddToConfig(this.addToConfig);
1✔
408
    scanner.setAnnotationClass(this.annotationClass);
1✔
409
    scanner.setMarkerInterface(this.markerInterface);
1✔
410
    scanner.setExcludeFilters(this.excludeFilters = mergeExcludeFilters());
1✔
411
    scanner.setSqlSessionFactory(this.sqlSessionFactory);
1✔
412
    scanner.setSqlSessionTemplate(this.sqlSessionTemplate);
1✔
413
    scanner.setSqlSessionFactoryBeanName(this.sqlSessionFactoryBeanName);
1✔
414
    scanner.setSqlSessionTemplateBeanName(this.sqlSessionTemplateBeanName);
1✔
415
    scanner.setResourceLoader(this.applicationContext);
1✔
416
    scanner.setBeanNameGenerator(this.nameGenerator);
1✔
417
    scanner.setMapperFactoryBeanClass(this.mapperFactoryBeanClass);
1✔
418
    if (StringUtils.hasText(lazyInitialization)) {
1✔
419
      scanner.setLazyInitialization(Boolean.valueOf(lazyInitialization));
1✔
420
    }
421
    if (StringUtils.hasText(defaultScope)) {
1✔
422
      scanner.setDefaultScope(defaultScope);
1✔
423
    }
424
    scanner.registerFilters();
1✔
425
    scanner.scan(
1✔
426
        StringUtils.tokenizeToStringArray(this.basePackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS));
1✔
427
  }
1✔
428

429
  /*
430
   * BeanDefinitionRegistries are called early in application startup, before BeanFactoryPostProcessors. This means that
431
   * PropertyResourceConfigurers will not have been loaded and any property substitution of this class' properties will
432
   * fail. To avoid this, find any PropertyResourceConfigurers defined in the context and run them on this class' bean
433
   * definition. Then update the values.
434
   */
435
  private void processPropertyPlaceHolders() {
436
    Map<String, PropertyResourceConfigurer> prcs = applicationContext.getBeansOfType(PropertyResourceConfigurer.class,
1✔
437
        false, false);
438

439
    if (!prcs.isEmpty() && applicationContext instanceof ConfigurableApplicationContext) {
1!
440
      BeanDefinition mapperScannerBean = ((ConfigurableApplicationContext) applicationContext).getBeanFactory()
1✔
441
          .getBeanDefinition(beanName);
1✔
442

443
      // PropertyResourceConfigurer does not expose any methods to explicitly perform
444
      // property placeholder substitution. Instead, create a BeanFactory that just
445
      // contains this mapper scanner and post process the factory.
446
      DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
1✔
447
      factory.registerBeanDefinition(beanName, mapperScannerBean);
1✔
448

449
      for (PropertyResourceConfigurer prc : prcs.values()) {
1✔
450
        prc.postProcessBeanFactory(factory);
1✔
451
      }
1✔
452

453
      PropertyValues values = mapperScannerBean.getPropertyValues();
1✔
454

455
      this.basePackage = getPropertyValue("basePackage", values);
1✔
456
      this.sqlSessionFactoryBeanName = getPropertyValue("sqlSessionFactoryBeanName", values);
1✔
457
      this.sqlSessionTemplateBeanName = getPropertyValue("sqlSessionTemplateBeanName", values);
1✔
458
      this.lazyInitialization = getPropertyValue("lazyInitialization", values);
1✔
459
      this.defaultScope = getPropertyValue("defaultScope", values);
1✔
460
      this.rawExcludeFilters = getPropertyValueForTypeFilter("rawExcludeFilters", values);
1✔
461
    }
462
    this.basePackage = Optional.ofNullable(this.basePackage).map(getEnvironment()::resolvePlaceholders).orElse(null);
1✔
463
    this.sqlSessionFactoryBeanName = Optional.ofNullable(this.sqlSessionFactoryBeanName)
1✔
464
        .map(getEnvironment()::resolvePlaceholders).orElse(null);
1✔
465
    this.sqlSessionTemplateBeanName = Optional.ofNullable(this.sqlSessionTemplateBeanName)
1✔
466
        .map(getEnvironment()::resolvePlaceholders).orElse(null);
1✔
467
    this.lazyInitialization = Optional.ofNullable(this.lazyInitialization).map(getEnvironment()::resolvePlaceholders)
1✔
468
        .orElse(null);
1✔
469
    this.defaultScope = Optional.ofNullable(this.defaultScope).map(getEnvironment()::resolvePlaceholders).orElse(null);
1✔
470
  }
1✔
471

472
  private Environment getEnvironment() {
473
    return this.applicationContext.getEnvironment();
1✔
474
  }
475

476
  private String getPropertyValue(String propertyName, PropertyValues values) {
477
    PropertyValue property = values.getPropertyValue(propertyName);
1✔
478

479
    if (property == null) {
1✔
480
      return null;
1✔
481
    }
482

483
    Object value = property.getValue();
1✔
484

485
    if (value == null) {
1!
486
      return null;
×
487
    } else if (value instanceof String) {
1✔
488
      return value.toString();
1✔
489
    } else if (value instanceof TypedStringValue) {
1!
490
      return ((TypedStringValue) value).getValue();
1✔
491
    } else {
492
      return null;
×
493
    }
494
  }
495

496
  @SuppressWarnings("unchecked")
497
  private List<Map<String, String>> getPropertyValueForTypeFilter(String propertyName, PropertyValues values) {
498
    PropertyValue property = values.getPropertyValue(propertyName);
1✔
499
    Object value;
500
    if (property == null || (value = property.getValue()) == null || !(value instanceof List<?>)) {
1!
501
      return null;
1✔
502
    }
503
    return (List<Map<String, String>>) value;
1✔
504
  }
505

506
  private List<TypeFilter> mergeExcludeFilters() {
507
    List<TypeFilter> typeFilters = new ArrayList<>();
1✔
508
    if (this.rawExcludeFilters == null || this.rawExcludeFilters.isEmpty()) {
1✔
509
      return this.excludeFilters;
1✔
510
    }
511
    if (this.excludeFilters != null && !this.excludeFilters.isEmpty()) {
1✔
512
      typeFilters.addAll(this.excludeFilters);
1✔
513
    }
514
    try {
515
      for (Map<String, String> typeFilter : this.rawExcludeFilters) {
1✔
516
        typeFilters.add(
1✔
517
            createTypeFilter(typeFilter.get("type"), typeFilter.get("expression"), this.getClass().getClassLoader()));
1✔
518
      }
1✔
519
    } catch (ClassNotFoundException exception) {
1✔
520
      throw new RuntimeException("ClassNotFoundException occur when to load the Specified excludeFilter classes.",
1✔
521
          exception);
522
    }
1✔
523
    return typeFilters;
1✔
524
  }
525

526
  @SuppressWarnings("unchecked")
527
  private TypeFilter createTypeFilter(String filterType, String expression, @Nullable ClassLoader classLoader)
528
      throws ClassNotFoundException {
529

530
    if (this.processPropertyPlaceHolders) {
1✔
531
      expression = this.getEnvironment().resolvePlaceholders(expression);
1✔
532
    }
533

534
    switch (filterType) {
1!
535
      case "annotation":
536
        Class<?> filterAnno = ClassUtils.forName(expression, classLoader);
1✔
537
        if (!Annotation.class.isAssignableFrom(filterAnno)) {
1✔
538
          throw new IllegalArgumentException(
1✔
539
              "Class is not assignable to [" + Annotation.class.getName() + "]: " + expression);
1✔
540
        }
541
        return new AnnotationTypeFilter((Class<Annotation>) filterAnno);
1✔
542
      case "custom":
543
        Class<?> filterClass = ClassUtils.forName(expression, classLoader);
1✔
544
        if (!TypeFilter.class.isAssignableFrom(filterClass)) {
1✔
545
          throw new IllegalArgumentException(
1✔
546
              "Class is not assignable to [" + TypeFilter.class.getName() + "]: " + expression);
1✔
547
        }
548
        return (TypeFilter) BeanUtils.instantiateClass(filterClass);
1✔
549
      case "assignable":
550
        return new AssignableTypeFilter(ClassUtils.forName(expression, classLoader));
1✔
551
      case "regex":
552
        return new RegexPatternTypeFilter(Pattern.compile(expression));
1✔
553
      case "aspectj":
554
        return new AspectJTypeFilter(expression, classLoader);
1✔
555
      default:
556
        throw new IllegalArgumentException("Unsupported filter type: " + filterType);
×
557
    }
558
  }
559

560
}
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

© 2025 Coveralls, Inc