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

devonfw / IDEasy / 29498958581

16 Jul 2026 12:38PM UTC coverage: 72.572% (+0.07%) from 72.506%
29498958581

Pull #2162

github

web-flow
Merge 0ea353dcb into de5a980f5
Pull Request #2162: #2156: Add secret placeholders for configuration templates

4928 of 7500 branches covered (65.71%)

Branch coverage included in aggregate %.

12718 of 16815 relevant lines covered (75.63%)

3.2 hits per line

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

84.12
cli/src/main/java/com/devonfw/tools/ide/commandlet/AbstractUpdateCommandlet.java
1
package com.devonfw.tools.ide.commandlet;
2

3
import java.io.IOException;
4
import java.nio.file.Files;
5
import java.nio.file.Path;
6
import java.util.HashMap;
7
import java.util.HashSet;
8
import java.util.Iterator;
9
import java.util.List;
10
import java.util.Map;
11
import java.util.Set;
12
import java.util.regex.Matcher;
13
import java.util.regex.Pattern;
14
import java.util.stream.Stream;
15

16
import org.slf4j.Logger;
17
import org.slf4j.LoggerFactory;
18

19
import com.devonfw.tools.ide.cli.CliException;
20
import com.devonfw.tools.ide.context.AbstractIdeContext;
21
import com.devonfw.tools.ide.context.IdeContext;
22
import com.devonfw.tools.ide.context.IdeStartContextImpl;
23
import com.devonfw.tools.ide.git.GitContext;
24
import com.devonfw.tools.ide.git.GitUrl;
25
import com.devonfw.tools.ide.git.repository.RepositoryCommandlet;
26
import com.devonfw.tools.ide.io.FileAccess;
27
import com.devonfw.tools.ide.property.FlagProperty;
28
import com.devonfw.tools.ide.property.StringProperty;
29
import com.devonfw.tools.ide.step.Step;
30
import com.devonfw.tools.ide.tool.LocalToolCommandlet;
31
import com.devonfw.tools.ide.tool.ToolCommandlet;
32
import com.devonfw.tools.ide.tool.ToolEdition;
33
import com.devonfw.tools.ide.tool.ToolEditionAndVersion;
34
import com.devonfw.tools.ide.tool.ToolInstallRequest;
35
import com.devonfw.tools.ide.tool.custom.CustomToolCommandlet;
36
import com.devonfw.tools.ide.tool.custom.CustomToolMetadata;
37
import com.devonfw.tools.ide.tool.extra.ExtraToolInstallation;
38
import com.devonfw.tools.ide.tool.extra.ExtraTools;
39
import com.devonfw.tools.ide.tool.extra.ExtraToolsMapper;
40
import com.devonfw.tools.ide.variable.IdeVariables;
41
import com.devonfw.tools.ide.version.VersionIdentifier;
42

43
/**
44
 * Abstract {@link Commandlet} base-class for both {@link UpdateCommandlet} and {@link CreateCommandlet}.
45
 */
46
public abstract class AbstractUpdateCommandlet extends Commandlet {
47

48
  private static final Logger LOG = LoggerFactory.getLogger(AbstractUpdateCommandlet.class);
3✔
49

50
  private static final String MESSAGE_CODE_REPO_URL = """
51
      No code repository was given after '--code'.
52
      Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc
53
      Please enter the code repository below that includes your settings folder.""";
54

55
  private static final String MESSAGE_SETTINGS_REPO_URL = """
56
      No settings found at {} and no SETTINGS_URL is defined.
57
      Further details can be found here: https://github.com/devonfw/IDEasy/blob/main/documentation/settings.adoc
58
      Please contact the technical lead of your project to get the SETTINGS_URL for your project to enter.
59
      In case you just want to test IDEasy you may simply hit return to install the default settings.""";
60

61
  private static final Pattern SECRET_PLACEHOLDER_PATTERN = Pattern.compile("\\$\\[secret:([a-zA-Z0-9_-]+)]");
4✔
62

63
  /** {@link StringProperty} for the settings repository URL. */
64
  public final StringProperty settingsRepo;
65

66
  /** {@link FlagProperty} for skipping installation/updating of tools. */
67
  public final FlagProperty skipTools;
68

69
  /** {@link FlagProperty} for skipping the setup of git repositories. */
70
  public final FlagProperty skipRepositories;
71

72
  /** {@link FlagProperty} to force the update of the settings git repository. */
73
  public final FlagProperty forcePull;
74

75
  /** {@link FlagProperty} to force the installation/update of plugins. */
76
  public final FlagProperty forcePlugins;
77

78
  /** {@link FlagProperty} to force the setup of git repositories. */
79
  public final FlagProperty forceRepositories;
80

81
  /**
82
   * The constructor.
83
   *
84
   * @param context the {@link IdeContext}.
85
   */
86
  public AbstractUpdateCommandlet(IdeContext context) {
87

88
    super(context);
3✔
89
    addKeyword(getName());
4✔
90
    this.skipTools = add(new FlagProperty("--skip-tools"));
9✔
91
    this.skipRepositories = add(new FlagProperty("--skip-repositories"));
9✔
92
    this.forcePull = add(new FlagProperty("--force-pull"));
9✔
93
    this.forcePlugins = add(new FlagProperty("--force-plugins"));
9✔
94
    this.forceRepositories = add(new FlagProperty("--force-repositories"));
9✔
95
    this.settingsRepo = new StringProperty("", false, "settingsRepository");
8✔
96
  }
1✔
97

98
  @Override
99
  protected void doRun() {
100

101
    IdeStartContextImpl startContext = ((AbstractIdeContext) this.context).getStartContext();
5✔
102
    startContext.setForcePull(forcePull.isTrue());
5✔
103
    startContext.setForcePlugins(forcePlugins.isTrue());
5✔
104
    startContext.setForceRepositories(forceRepositories.isTrue());
5✔
105

106
    updateSettings();
2✔
107
    updateConf();
2✔
108
    reloadContext();
2✔
109
    this.context.verifyIdeMinVersion(true);
4✔
110

111
    updateSoftware();
2✔
112
    updateRepositories();
2✔
113
    createStartScripts();
2✔
114
  }
1✔
115

116
  private void reloadContext() {
117

118
    ((AbstractIdeContext) this.context).reload();
4✔
119
  }
1✔
120

121
  private void updateConf() {
122

123
    Path templatesFolder = this.context.getSettingsPath().resolve(IdeContext.FOLDER_TEMPLATES);
6✔
124
    if (!Files.exists(templatesFolder)) {
5✔
125
      Path legacyTemplatesFolder = this.context.getSettingsPath().resolve(IdeContext.FOLDER_LEGACY_TEMPLATES);
6✔
126
      if (Files.exists(legacyTemplatesFolder)) {
5!
127
        templatesFolder = legacyTemplatesFolder;
×
128
      } else {
129
        LOG.warn("Templates folder is missing in settings repository.");
3✔
130
        return;
1✔
131
      }
132
    }
133

134
    Step step = this.context.newStep("Copy configuration templates", templatesFolder);
11✔
135
    final Path finalTemplatesFolder = templatesFolder;
2✔
136
    step.run(() -> setupConf(finalTemplatesFolder, this.context.getIdeHome()));
13✔
137
  }
1✔
138

139
  private void setupConf(Path template, Path conf) {
140

141
    List<Path> children = this.context.getFileAccess().listChildren(template, f -> true);
9✔
142
    for (Path child : children) {
10✔
143

144
      String basename = child.getFileName().toString();
4✔
145
      Path confPath = conf.resolve(basename);
4✔
146

147
      if (Files.isDirectory(child)) {
5✔
148
        if (!Files.isDirectory(confPath)) {
5✔
149
          this.context.getFileAccess().mkdirs(confPath);
5✔
150
        }
151
        setupConf(child, confPath);
5✔
152
      } else if (Files.isRegularFile(child)) {
5!
153
        if (Files.isRegularFile(confPath)) {
5✔
154
          LOG.debug("Configuration {} already exists - skipping to copy from {}", confPath, child);
6✔
155
        } else {
156
          if (!basename.equals("settings.xml")) {
4!
157
            copyTemplateWithSecretReplacement(child, confPath);
4✔
158
          }
159
        }
160
      }
161
    }
1✔
162
  }
1✔
163

164
  private void copyTemplateWithSecretReplacement(Path source, Path destination) {
165
    LOG.info("Copying template {} to {}.", source, destination.getParent());
6✔
166
    String content = this.context.getFileAccess().readFileContent(source);
6✔
167
    Matcher matcher = SECRET_PLACEHOLDER_PATTERN.matcher(content);
4✔
168
    if (!matcher.find()) {
3✔
169
      // No placeholder - use original copy operation with destination as directory
170
      this.context.getFileAccess().copy(source, destination.getParent());
7✔
171
      return;
1✔
172
    }
173
    matcher.reset();
3✔
174
    Map<String, String> secretValues = new HashMap<>();
4✔
175
    StringBuilder result = new StringBuilder();
4✔
176
    while (matcher.find()) {
3✔
177
      String secretName = matcher.group(1);
4✔
178
      String secretValue = secretValues.computeIfAbsent(secretName,
7✔
179
          name -> this.context.askForInput("Please enter secret value for " + name + ":"));
6✔
180
      matcher.appendReplacement(result, Matcher.quoteReplacement(secretValue));
6✔
181
    }
1✔
182
    matcher.appendTail(result);
4✔
183
    this.context.getFileAccess().writeFileContent(result.toString(), destination, true);
8✔
184
  }
1✔
185

186
  /**
187
   * Updates the settings repository in IDE_HOME/settings by either cloning if no such repository exists or pulling if the repository exists then saves the
188
   * latest current commit ID in the file ".commit.id".
189
   */
190
  protected void updateSettings() {
191

192
    boolean codeRepository = this.context.isSettingsCodeRepository();
4✔
193
    if (codeRepository && !(this.context.isForceMode() || forcePull.isTrue())) {
2!
194
      LOG.info("Skipping git pull in settings due to code repository. Use --force-pull to enforce pulling.");
×
195
      return;
×
196
    }
197
    this.context.newStep(getStepMessage()).run(() -> updateSettingsInStep(codeRepository));
14✔
198
  }
1✔
199

200
  protected String getStepMessage() {
201

202
    return "update (pull) settings repository";
2✔
203
  }
204

205
  private void updateSettingsInStep(boolean codeRepository) {
206
    Path settingsPath = this.context.getSettingsPath();
4✔
207
    if (!codeRepository) {
2!
208
      boolean settingsRepository = this.context.getGitContext().isGitRepo(settingsPath);
6✔
209
      if (!settingsRepository) {
2✔
210
        if (Files.exists(settingsPath)) {
5!
211
          if (!this.context.getFileAccess().isEmptyDir(settingsPath)) {
×
212
            this.context.askToContinue(
×
213
                "Your settings repository seems to be broken ('.git' folder not present). "
214
                    + "We can fix this by moving  your settings the backed up. "
215
                    + "You will be asked for the settings git URL and your settings will be cloned from scratch. "
216
                    + "Do you want to proceed?"
217
            );
218
          }
219
          this.context.getFileAccess().backup(settingsPath);
×
220
        }
221
        GitUrl gitUrl = getOrAskSettingsUrl();
3✔
222
        checkProjectNameConvention(gitUrl.getProjectName());
4✔
223
        initializeRepository(gitUrl);
3✔
224
        return;
1✔
225
      }
226
    }
227
    GitContext gitContext = this.context.getGitContext();
4✔
228
    if (gitContext.hasUntrackedFiles(settingsPath)) {
4!
229
      gitContext.pullSafelyWithStash(settingsPath);
×
230
    } else {
231
      gitContext.pull(settingsPath);
3✔
232
    }
233
    this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
8✔
234
  }
1✔
235

236
  private GitUrl getOrAskSettingsUrl() {
237

238
    String repository = this.settingsRepo.getValue();
5✔
239
    repository = handleDefaultRepository(repository);
4✔
240
    String userPromt;
241
    String defaultUrl;
242
    if (isCodeRepository()) {
3✔
243
      userPromt = "Code repository URL:";
2✔
244
      defaultUrl = null;
2✔
245
      LOG.info(MESSAGE_CODE_REPO_URL);
4✔
246
    } else {
247
      userPromt = "Settings URL [" + IdeContext.DEFAULT_SETTINGS_REPO_URL + "]:";
2✔
248
      defaultUrl = IdeContext.DEFAULT_SETTINGS_REPO_URL;
2✔
249
      LOG.info(MESSAGE_SETTINGS_REPO_URL, this.context.getSettingsPath());
6✔
250
    }
251
    GitUrl gitUrl = null;
2✔
252
    if (repository != null) {
2✔
253
      gitUrl = GitUrl.of(repository);
3✔
254
    }
255
    while ((gitUrl == null) || !gitUrl.isValid()) {
5!
256
      repository = this.context.askForInput(userPromt, defaultUrl);
6✔
257
      repository = handleDefaultRepository(repository);
4✔
258
      gitUrl = GitUrl.of(repository);
3✔
259
      if (!gitUrl.isValid()) {
3!
260
        LOG.warn("The input URL is not valid, please try again.");
×
261
      }
262
    }
263
    return gitUrl;
2✔
264
  }
265

266
  private String handleDefaultRepository(String repository) {
267
    if ("-".equals(repository)) {
4✔
268
      if (isCodeRepository()) {
3✔
269
        LOG.warn("'-' is found after '--code'. This is invalid.");
3✔
270
        repository = null;
3✔
271
      } else {
272
        LOG.info("'-' was found for settings repository, the default settings repository '{}' will be used.", IdeContext.DEFAULT_SETTINGS_REPO_URL);
4✔
273
        repository = IdeContext.DEFAULT_SETTINGS_REPO_URL;
2✔
274
      }
275
    }
276
    return repository;
2✔
277
  }
278

279
  private void checkProjectNameConvention(String projectName) {
280
    boolean isSettingsRepo = projectName.contains(IdeContext.SETTINGS_REPOSITORY_KEYWORD);
4✔
281
    boolean codeRepository = isCodeRepository();
3✔
282
    if (isSettingsRepo == codeRepository) {
3✔
283
      String warningTemplate;
284
      if (codeRepository) {
2✔
285
        warningTemplate = """
3✔
286
            Your git URL is pointing to the project name {} that contains the keyword '{}'.
287
            Therefore we assume that you did a mistake by adding the '--code' option to the ide project creation.
288
            Do you really want to create the project?""";
289
      } else {
290
        warningTemplate = """
2✔
291
            Your git URL is pointing to the project name {} that does not contain the keyword ''{}''.
292
            Therefore we assume that you forgot to add the '--code' option to the ide project creation.
293
            Do you really want to create the project?""";
294
      }
295
      this.context.askToContinue(warningTemplate, projectName, IdeContext.SETTINGS_REPOSITORY_KEYWORD);
14✔
296
    }
297
  }
1✔
298

299
  private void initializeRepository(GitUrl gitUrl) {
300

301
    GitContext gitContext = this.context.getGitContext();
4✔
302
    Path settingsPath = this.context.getSettingsPath();
4✔
303
    Path repoPath = settingsPath;
2✔
304
    boolean codeRepository = isCodeRepository();
3✔
305
    if (codeRepository) {
2✔
306
      // clone the given code repository into IDE_HOME/workspaces/main
307
      repoPath = context.getWorkspacePath().resolve(gitUrl.getProjectName());
7✔
308
    }
309
    gitContext.pullOrClone(gitUrl, repoPath);
4✔
310
    if (codeRepository) {
2✔
311
      // check for settings folder and create symlink to IDE_HOME/settings
312
      Path settingsFolder = repoPath.resolve(IdeContext.FOLDER_SETTINGS);
4✔
313
      if (Files.exists(settingsFolder)) {
5!
314
        context.getFileAccess().symlink(settingsFolder, settingsPath);
×
315
      } else {
316
        throw new CliException("Invalid code repository " + gitUrl + ": missing a settings folder at " + settingsFolder);
9✔
317
      }
318
    }
319
    this.context.getGitContext().saveCurrentCommitId(settingsPath, this.context.getSettingsCommitIdPath());
8✔
320
  }
1✔
321

322
  private void updateSoftware() {
323

324
    if (this.skipTools.isTrue()) {
4✔
325
      LOG.info("Skipping installation/update of tools as specified by the user.");
3✔
326
      return;
1✔
327
    }
328
    Step step = this.context.newStep("Install or update software");
5✔
329
    step.run(() -> doUpdateSoftwareStep(step));
10✔
330
  }
1✔
331

332
  private void doUpdateSoftwareStep(Step step) {
333

334
    Set<ToolCommandlet> toolCommandlets = new HashSet<>();
4✔
335
    CommandletManager commandletManager = this.context.getCommandletManager();
4✔
336
    // installed tools in IDE_HOME/software
337
    List<Path> softwarePaths = this.context.getFileAccess().listChildren(this.context.getSoftwarePath(), Files::isDirectory);
14✔
338
    for (Path softwarePath : softwarePaths) {
10✔
339
      String toolName = softwarePath.getFileName().toString();
4✔
340
      ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(toolName);
4✔
341
      if (toolCommandlet != null) {
2!
342
        toolCommandlets.add(toolCommandlet);
4✔
343
      }
344
    }
1✔
345

346
    // regular tools in $IDE_TOOLS
347
    List<String> regularTools = IdeVariables.IDE_TOOLS.get(this.context);
6✔
348
    if (regularTools != null) {
2!
349
      for (String regularTool : regularTools) {
10✔
350
        ToolCommandlet toolCommandlet = commandletManager.getToolCommandlet(regularTool);
4✔
351
        if (toolCommandlet == null) {
2!
352
          String displayName = (regularTool == null || regularTool.isBlank()) ? "<empty>" : "'" + regularTool + "'";
×
353
          LOG.error("Cannot install or update tool '{}''. No matching commandlet found. Please check your IDE_TOOLS configuration.", displayName);
×
354
        } else {
×
355
          toolCommandlets.add(toolCommandlet);
4✔
356
        }
357
      }
1✔
358
    }
359

360
    // custom tools in ide-custom-tools.json
361
    for (CustomToolMetadata customTool : this.context.getCustomToolRepository().getTools()) {
9!
362
      CustomToolCommandlet customToolCommandlet = new CustomToolCommandlet(this.context, customTool);
×
363
      toolCommandlets.add(customToolCommandlet);
×
364
    }
×
365

366
    // update/install the toolCommandlets
367
    for (ToolCommandlet toolCommandlet : toolCommandlets) {
10✔
368
      this.context.newStep("Install " + toolCommandlet.getName()).run(() -> toolCommandlet.install(false));
15✔
369
    }
1✔
370

371
    ExtraTools extraTools = ExtraToolsMapper.get().loadJsonFromFolder(this.context.getSettingsPath());
7✔
372
    if (extraTools != null) {
2✔
373
      List<String> toolNames = extraTools.getSortedToolNames();
3✔
374
      LOG.info("Found extra installation of the following tools: {}", toolNames);
4✔
375
      for (String tool : toolNames) {
10✔
376
        List<ExtraToolInstallation> installations = extraTools.getExtraInstallations(tool);
4✔
377
        this.context.newStep("Install extra version(s) of " + tool).run(() -> installExtraToolInstallations(tool, installations));
16✔
378
      }
1✔
379
    }
380
  }
1✔
381

382
  private void installExtraToolInstallations(String tool, List<ExtraToolInstallation> extraInstallations) {
383

384
    CommandletManager commandletManager = this.context.getCommandletManager();
4✔
385
    FileAccess fileAccess = this.context.getFileAccess();
4✔
386
    Path extraPath = this.context.getSoftwareExtraPath();
4✔
387
    LocalToolCommandlet toolCommandlet = commandletManager.getRequiredLocalToolCommandlet(tool);
4✔
388
    for (ExtraToolInstallation extraInstallation : extraInstallations) {
10✔
389
      ToolInstallRequest request = new ToolInstallRequest(false);
5✔
390
      String edition = extraInstallation.edition();
3✔
391
      if (edition == null) {
2✔
392
        edition = toolCommandlet.getConfiguredEdition();
3✔
393
      }
394
      ToolEdition toolEdition = new ToolEdition(tool, edition);
6✔
395
      VersionIdentifier version = extraInstallation.version();
3✔
396
      request.setRequested(new ToolEditionAndVersion(toolEdition, version));
7✔
397
      Path extraToolPath = extraPath.resolve(tool);
4✔
398
      Path toolPath = extraToolPath.resolve(extraInstallation.name());
5✔
399
      request.setToolPathForExtraInstallation(toolPath);
3✔
400
      toolCommandlet.install(request);
4✔
401
    }
1✔
402
  }
1✔
403

404
  private void updateRepositories() {
405

406
    if (this.skipRepositories.isTrue()) {
4!
407
      if (this.forceRepositories.isTrue()) {
×
408
        LOG.warn("Options to skip and force repositories are incompatible and should not be combined. Ignoring --force-repositories to proceed.");
×
409
      }
410
      LOG.info("Skipping setup of repositories as specified by the user.");
×
411
      return;
×
412
    }
413
    RepositoryCommandlet repositoryCommandlet = this.context.getCommandletManager().getCommandlet(RepositoryCommandlet.class);
7✔
414
    repositoryCommandlet.reset();
2✔
415
    repositoryCommandlet.run();
2✔
416
  }
1✔
417

418
  private void createStartScripts() {
419

420
    List<String> ides = IdeVariables.CREATE_START_SCRIPTS.get(this.context);
6✔
421
    if (ides == null) {
2✔
422
      LOG.info("Variable CREATE_START_SCRIPTS is undefined - skipping start script creation.");
3✔
423
      return;
1✔
424
    }
425
    for (String ide : ides) {
10✔
426
      ToolCommandlet tool = this.context.getCommandletManager().getToolCommandlet(ide);
6✔
427
      if (tool == null) {
2!
428
        LOG.error("Undefined IDE '{}' configured in variable CREATE_START_SCRIPTS.", ide);
×
429
      } else {
430
        createStartScript(ide);
3✔
431
      }
432
    }
1✔
433
  }
1✔
434

435
  private void createStartScript(String ide) {
436

437
    LOG.info("Creating start scripts for {}", ide);
4✔
438
    Path workspaces = this.context.getIdeHome().resolve(IdeContext.FOLDER_WORKSPACES);
6✔
439
    try (Stream<Path> childStream = Files.list(workspaces)) {
3✔
440
      Iterator<Path> iterator = childStream.iterator();
3✔
441
      while (iterator.hasNext()) {
3✔
442
        Path child = iterator.next();
4✔
443
        if (Files.isDirectory(child)) {
5!
444
          createStartScript(ide, child.getFileName().toString());
6✔
445
        }
446
      }
1✔
447
    } catch (IOException e) {
×
448
      throw new RuntimeException("Failed to list children of directory " + workspaces, e);
×
449
    }
1✔
450
  }
1✔
451

452
  private void createStartScript(String ide, String workspace) {
453

454
    Path ideHome = this.context.getIdeHome();
4✔
455
    String scriptName = ide + "-" + workspace;
4✔
456
    boolean windows = this.context.getSystemInfo().isWindows();
5✔
457
    if (windows) {
2!
458
      scriptName = scriptName + ".bat";
×
459
    } else {
460
      scriptName = scriptName + ".sh";
3✔
461
    }
462
    Path scriptPath = ideHome.resolve(scriptName);
4✔
463
    if (Files.exists(scriptPath)) {
5!
464
      return;
×
465
    }
466
    String scriptContent;
467
    if (windows) {
2!
468
      scriptContent = "@echo off\r\n" + "pushd %~dp0\r\n" + "cd workspaces/" + workspace + "\r\n" + "call ide " + ide + "\r\n" + "popd\r\n";
×
469
    } else {
470
      scriptContent = "#!/usr/bin/env bash\n" + "cd \"$(dirname \"$0\")\"\n" + "cd workspaces/" + workspace + "\n" + "ideasy " + ide + "\n";
4✔
471
    }
472
    FileAccess fileAccess = this.context.getFileAccess();
4✔
473
    fileAccess.writeFileContent(scriptContent, scriptPath);
4✔
474
    fileAccess.makeExecutable(scriptPath);
3✔
475
  }
1✔
476

477
  /**
478
   * Judge if the repository is a code repository.
479
   *
480
   * @return true when the repository is a code repository, otherwise false.
481
   */
482
  protected boolean isCodeRepository() {
483
    return false;
2✔
484
  }
485

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