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

DoclerLabs / api-client-generator / 9254068657

27 May 2024 11:30AM UTC coverage: 86.981% (-1.4%) from 88.428%
9254068657

push

github

web-flow
Merge pull request #112 from DoclerLabs/php81

php 8.1 features

106 of 172 new or added lines in 20 files covered. (61.63%)

4 existing lines in 2 files now uncovered.

2913 of 3349 relevant lines covered (86.98%)

4.92 hits per line

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

99.0
/src/Generator/SchemaMapperGenerator.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace DoclerLabs\ApiClientGenerator\Generator;
6

7
use DateTimeImmutable;
8
use DoclerLabs\ApiClientException\UnexpectedResponseBodyException;
9
use DoclerLabs\ApiClientGenerator\Ast\Builder\ParameterBuilder;
10
use DoclerLabs\ApiClientGenerator\Ast\ParameterNode;
11
use DoclerLabs\ApiClientGenerator\Entity\Field;
12
use DoclerLabs\ApiClientGenerator\Input\Specification;
13
use DoclerLabs\ApiClientGenerator\Naming\SchemaMapperNaming;
14
use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection;
15
use PhpParser\Node\Expr\Variable;
16
use PhpParser\Node\Stmt;
17
use PhpParser\Node\Stmt\ClassMethod;
18

19
class SchemaMapperGenerator extends MutatorAccessorClassGeneratorAbstract
20
{
21
    public const NAMESPACE_SUBPATH = '\\Schema\\Mapper';
22

23
    public const SUBDIRECTORY = 'Schema/Mapper/';
24

25
    private array $mapMethodThrownExceptions;
26

27
    public function generate(Specification $specification, PhpFileCollection $fileRegistry): void
28
    {
29
        foreach ($specification->getCompositeResponseFields() as $field) {
22✔
30
            /** @var Field $field */
31
            $this->generateMapper($fileRegistry, $field);
22✔
32
        }
33
    }
22✔
34

35
    protected function generateMapper(PhpFileCollection $fileRegistry, Field $root): void
36
    {
37
        $this->mapMethodThrownExceptions = [];
22✔
38

39
        $className    = SchemaMapperNaming::getClassName($root);
22✔
40
        $classBuilder = $this->builder
22✔
41
            ->class($className)
22✔
42
            ->implement('SchemaMapperInterface')
22✔
43
            ->addStmts($this->generateProperties($root))
22✔
44
            ->addStmt($this->generateConstructor($root))
22✔
45
            ->addStmt($this->generateMap($root));
22✔
46

47
        $this->registerFile($fileRegistry, $classBuilder, self::SUBDIRECTORY, self::NAMESPACE_SUBPATH);
22✔
48
    }
22✔
49

50
    protected function generateProperties(Field $root): array
51
    {
52
        if ($this->phpVersion->isConstructorPropertyPromotionSupported()) {
22✔
53
            return [];
14✔
54
        }
55

56
        $properties = [];
8✔
57
        if ($root->isObject()) {
8✔
58
            $alreadyInjected = [];
8✔
59
            foreach ($root->getObjectProperties() as $child) {
8✔
60
                if ($child->isComposite()) {
7✔
61
                    $childClassName = SchemaMapperNaming::getClassName($child);
6✔
62
                    if (!isset($alreadyInjected[$childClassName])) {
6✔
63
                        $propertyName = SchemaMapperNaming::getPropertyName($child);
6✔
64
                        $properties[] = $this->builder->localProperty($propertyName, $childClassName, $childClassName, readonly: true);
6✔
65

66
                        $alreadyInjected[$childClassName] = true;
6✔
67
                    }
68
                }
69
            }
70
        }
71

72
        if ($root->isArrayOfObjects()) {
8✔
73
            $child = $root->getArrayItem();
2✔
74
            if ($child !== null && $child->isComposite()) {
2✔
75
                $propertyName   = SchemaMapperNaming::getPropertyName($child);
2✔
76
                $childClassName = SchemaMapperNaming::getClassName($child);
2✔
77
                $properties[]   = $this->builder->localProperty(
2✔
78
                    $propertyName,
79
                    $childClassName,
80
                    $childClassName,
81
                    readonly: true
2✔
82
                );
83
            }
84
        }
85

86
        return $properties;
8✔
87
    }
88

89
    protected function generateConstructor(Field $root): ?ClassMethod
90
    {
91
        $params     = [];
22✔
92
        $paramInits = [];
22✔
93

94
        if ($root->isObject()) {
22✔
95
            $alreadyInjected = [];
22✔
96
            foreach ($root->getObjectProperties() as $child) {
22✔
97
                if ($child->isComposite()) {
19✔
98
                    $childClassName = SchemaMapperNaming::getClassName($child);
16✔
99
                    if (!isset($alreadyInjected[$childClassName])) {
16✔
100
                        $propertyName = SchemaMapperNaming::getPropertyName($child);
16✔
101
                        $params[]     = $this->builder
16✔
102
                            ->param($propertyName)
16✔
103
                            ->setType($childClassName);
16✔
104

105
                        $paramInits[] = $this->builder->assign(
16✔
106
                            $this->builder->localPropertyFetch($propertyName),
16✔
107
                            $this->builder->var($propertyName)
16✔
108
                        );
109
                        $alreadyInjected[$childClassName] = true;
16✔
110
                    }
111
                }
112
            }
113
        }
114

115
        if ($root->isArrayOfObjects()) {
22✔
116
            $child = $root->getArrayItem();
4✔
117
            if ($child !== null && $child->isComposite()) {
4✔
118
                $propertyName = SchemaMapperNaming::getPropertyName($child);
4✔
119
                $params[]     = $this->builder
4✔
120
                    ->param($propertyName)
4✔
121
                    ->setType(SchemaMapperNaming::getClassName($child));
4✔
122

123
                $paramInits[] = $this->builder->assign(
4✔
124
                    $this->builder->localPropertyFetch($propertyName),
4✔
125
                    $this->builder->var($propertyName)
4✔
126
                );
127
            }
128
        }
129

130
        if (empty($params)) {
22✔
131
            return null;
22✔
132
        }
133

134
        if ($this->phpVersion->isConstructorPropertyPromotionSupported()) {
16✔
135
            foreach ($params as $param) {
10✔
136
                $param->makePrivate();
10✔
137
            }
138
        }
139
        if ($this->phpVersion->isReadonlyPropertySupported()) {
16✔
140
            foreach ($params as $param) {
5✔
141
                $param->makeReadonly();
5✔
142
            }
143
        }
144

145
        $params = array_map(
16✔
146
            static fn (ParameterBuilder $param): ParameterNode => $param->getNode(),
16✔
147
            $params
148
        );
149

150
        $constructor = $this->builder
16✔
151
            ->method('__construct')
16✔
152
            ->makePublic()
16✔
153
            ->addParams($params)
16✔
154
            ->composeDocBlock($params);
16✔
155

156
        if (!$this->phpVersion->isConstructorPropertyPromotionSupported()) {
16✔
157
            $constructor->addStmts($paramInits);
6✔
158
        }
159

160
        return $constructor->getNode();
16✔
161
    }
162

163
    protected function generateMap(Field $root): ClassMethod
164
    {
165
        $statements = [];
22✔
166
        $builder    = $this->builder->param('payload');
22✔
167
        $builder->setType('array');
22✔
168
        $payloadParam = $builder->getNode();
22✔
169

170
        $payloadVariable = $this->builder->var('payload');
22✔
171
        $this->addImport(
22✔
172
            sprintf(
22✔
173
                '%s%s\\%s',
22✔
174
                $this->baseNamespace,
22✔
175
                SchemaGenerator::NAMESPACE_SUBPATH,
22✔
176
                $root->getPhpClassName()
22✔
177
            )
178
        );
179

180
        if ($root->isFreeFormObject()) {
22✔
181
            $statements[] = $this->generateMapStatementForFreeFormObject($root, $payloadVariable);
4✔
182
        } elseif ($root->isObject()) {
19✔
183
            $statements = array_merge(
19✔
184
                $statements,
185
                $this->generateMapStatementsForObject($root, $payloadVariable)
19✔
186
            );
187
        }
188

189
        if ($root->isArrayOfObjects()) {
22✔
190
            $statements = array_merge(
4✔
191
                $statements,
192
                $this->generateMapStatementsForArrayOfObjects($root, $payloadVariable)
4✔
193
            );
194
        }
195

196
        return $this->builder
22✔
197
            ->method('toSchema')
22✔
198
            ->makePublic()
22✔
199
            ->addParam($payloadParam)
22✔
200
            ->addStmts($statements)
22✔
201
            ->setReturnType($root->getPhpTypeHint())
22✔
202
            ->composeDocBlock(
22✔
203
                [$payloadParam],
22✔
204
                $root->getPhpDocType(false),
22✔
205
                array_keys($this->mapMethodThrownExceptions)
22✔
206
            )
207
            ->getNode();
22✔
208
    }
209

210
    protected function generateMapStatementsForArrayOfObjects(
211
        Field $field,
212
        Variable $payloadVariable
213
    ): array {
214
        $itemsVar     = $this->builder->var('items');
4✔
215
        $statements[] = $this->builder->assign($itemsVar, $this->builder->array([]));
4✔
216

217
        $payloadItemVariable = $this->builder->var('payloadItem');
4✔
218
        $itemMapper          = $this->builder->localPropertyFetch(
4✔
219
            SchemaMapperNaming::getPropertyName($field->getArrayItem())
4✔
220
        );
221
        $itemMapperCall      = $this->builder->methodCall($itemMapper, 'toSchema', [$payloadItemVariable]);
4✔
222
        $foreachStatements[] = $this->builder->appendToArray($itemsVar, $itemMapperCall);
4✔
223

224
        $statements[] = $this->builder->foreach($payloadVariable, $payloadItemVariable, $foreachStatements);
4✔
225

226
        $statements[] = $this->builder->return(
4✔
227
            $this->builder->new(
4✔
228
                $field->getPhpClassName(),
4✔
229
                [$this->builder->argument($itemsVar, false, true)]
4✔
230
            )
231
        );
232

233
        return $statements;
4✔
234
    }
235

236
    protected function generateMapStatementForFreeFormObject(Field $root, Variable $payloadVariable): Stmt
237
    {
238
        $schemaInit = $this->builder->new($root->getPhpClassName(), [$payloadVariable]);
4✔
239

240
        return $this->builder->return($schemaInit);
4✔
241
    }
242

243
    protected function hasComposite(array $fields): bool
244
    {
245
        foreach ($fields as $field) {
16✔
246
            if ($field->isComposite()) {
16✔
247
                return true;
16✔
248
            }
249
        }
250

251
        return false;
16✔
252
    }
253

254
    protected function generateMapStatementsForObject(Field $root, Variable $payloadVariable): array
255
    {
256
        $statements = [];
19✔
257

258
        $requiredFields        = [];
19✔
259
        $requiredItemsNames    = [];
19✔
260
        $requiredResponseItems = [];
19✔
261
        $optionalFields        = [];
19✔
262
        $optionalResponseItems = [];
19✔
263
        foreach ($root->getObjectProperties() as $property) {
19✔
264
            if ($property->isRequired()) {
19✔
265
                $requiredFields[]        = $property;
16✔
266
                $requiredItemName        = $this->builder->val($property->getName());
16✔
267
                $requiredItemsNames[]    = $requiredItemName;
16✔
268
                $requiredResponseItems[] = $this->builder->getArrayItem($payloadVariable, $requiredItemName);
16✔
269
            } else {
270
                $optionalFields[] = $property;
16✔
271
                if ($root->hasOneOf() || $root->hasAnyOf()) {
16✔
272
                    $optionalResponseItems[] = $payloadVariable;
9✔
273
                } else {
274
                    $optionalResponseItems[] = $this->builder->getArrayItem(
16✔
275
                        $payloadVariable,
276
                        $this->builder->val($property->getName())
16✔
277
                    );
278
                }
279
            }
280
        }
281

282
        if (!empty($requiredFields)) {
19✔
283
            $unexpectedResponseBodyException = 'UnexpectedResponseBodyException';
16✔
284
            $this->addImport(UnexpectedResponseBodyException::class);
16✔
285

286
            $missingFieldsVariable  = $this->builder->var('missingFields');
16✔
287
            $missingFieldsArrayKeys = $this->builder->funcCall('array_keys', [$payloadVariable]);
16✔
288
            $missingFieldsArrayDiff = $this->builder->funcCall(
16✔
289
                'array_diff',
16✔
290
                [$this->builder->array($requiredItemsNames), $missingFieldsArrayKeys]
16✔
291
            );
292
            $missingFieldsImplode = $this->builder->funcCall(
16✔
293
                'implode',
16✔
294
                [$this->builder->val(', '), $missingFieldsArrayDiff]
16✔
295
            );
296

297
            $statements[] = $this->builder->expr(
16✔
298
                $this->builder->assign($missingFieldsVariable, $missingFieldsImplode)
16✔
299
            );
300

301
            $requiredFieldsIfCondition = $this->builder->not(
16✔
302
                $this->builder->funcCall('empty', [$missingFieldsVariable])
16✔
303
            );
304

305
            $exceptionMsg = sprintf(
16✔
306
                'Required attributes for `%s` missing in the response body: ',
16✔
307
                $root->getPhpClassName()
16✔
308
            );
309

310
            $requiredFieldsIfStatements[] = $this->builder->throw(
16✔
311
                $unexpectedResponseBodyException,
312
                $this->builder->concat($this->builder->val($exceptionMsg), $missingFieldsVariable)
16✔
313
            );
314

315
            $statements[] = $this->builder->if($requiredFieldsIfCondition, $requiredFieldsIfStatements);
16✔
316

317
            $this->mapMethodThrownExceptions[$unexpectedResponseBodyException] = true;
16✔
318
        }
319

320
        $requiredVars = [];
19✔
321
        foreach ($requiredFields as $i => $field) {
19✔
322
            /** @var Field $field */
323
            if ($field->isComposite()) {
16✔
324
                if ($field->isNullable()) {
3✔
325
                    $requiredVars[] = $this->builder->ternary(
3✔
326
                        $this->builder->notEquals($requiredResponseItems[$i], $this->builder->val(null)),
3✔
327
                        $this->builder->methodCall(
3✔
328
                            $this->builder->localPropertyFetch(SchemaMapperNaming::getPropertyName($field)),
3✔
329
                            'toSchema',
3✔
330
                            [$requiredResponseItems[$i]]
3✔
331
                        ),
332
                        $this->builder->val(null)
3✔
333
                    );
334
                } else {
335
                    $requiredVars[] = $this->builder->methodCall(
3✔
336
                        $this->builder->localPropertyFetch(SchemaMapperNaming::getPropertyName($field)),
3✔
337
                        'toSchema',
3✔
338
                        [$requiredResponseItems[$i]]
3✔
339
                    );
340
                }
341
            } elseif ($field->isDate()) {
16✔
342
                $this->addImport(DateTimeImmutable::class);
6✔
343
                $newDateTime = $this->builder->new('DateTimeImmutable', [$requiredResponseItems[$i]]);
6✔
344
                if ($field->isNullable()) {
6✔
345
                    $requiredVars[] = $this->builder->ternary(
3✔
346
                        $this->builder->notEquals($requiredResponseItems[$i], $this->builder->val(null)),
3✔
347
                        $newDateTime,
348
                        $this->builder->val(null)
3✔
349
                    );
350
                } else {
351
                    $requiredVars[] = $newDateTime;
6✔
352
                }
353
            } elseif ($field->isEnum() && $this->phpVersion->isEnumSupported()) {
16✔
354
                $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $field->getPhpClassName()));
5✔
355
                $newEnum = $this->builder->staticCall($field->getPhpClassName(), 'from', [$requiredResponseItems[$i]]);
5✔
356
                if ($field->isNullable()) {
5✔
UNCOV
357
                    $requiredVars[] = $this->builder->ternary(
×
UNCOV
358
                        $this->builder->notEquals($requiredResponseItems[$i], $this->builder->val(null)),
×
359
                        $newEnum,
UNCOV
360
                        $this->builder->val(null)
×
361
                    );
362
                } else {
363
                    $requiredVars[] = $newEnum;
5✔
364
                }
365
            } else {
366
                $requiredVars[] = $requiredResponseItems[$i];
16✔
367
            }
368
        }
369
        $schemaInit = $this->builder->new($root->getPhpClassName(), $requiredVars);
19✔
370

371
        if (!empty($optionalFields)) {
19✔
372
            $schemaVar    = $this->builder->var('schema');
16✔
373
            $matchesVar   = $this->builder->var('matches');
16✔
374
            $statements[] = $this->builder->assign($schemaVar, $schemaInit);
16✔
375

376
            $tryCatchStatements = [];
16✔
377
            foreach ($optionalFields as $i => $field) {
16✔
378
                /** @var Field $field */
379
                if ($field->isComposite()) {
16✔
380
                    $mapper = $this->builder->localPropertyFetch(SchemaMapperNaming::getPropertyName($field));
16✔
381
                    if ($field->isNullable()) {
16✔
382
                        $optionalVar = $this->builder->ternary(
3✔
383
                            $this->builder->notEquals($optionalResponseItems[$i], $this->builder->val(null)),
3✔
384
                            $this->builder->methodCall(
3✔
385
                                $mapper,
386
                                'toSchema',
3✔
387
                                [$optionalResponseItems[$i]]
3✔
388
                            ),
389
                            $this->builder->val(null)
3✔
390
                        );
391
                    } else {
392
                        $optionalVar = $this->builder->methodCall(
16✔
393
                            $mapper,
394
                            'toSchema',
16✔
395
                            [$optionalResponseItems[$i]]
16✔
396
                        );
397
                    }
398
                } elseif ($field->isDate()) {
16✔
399
                    $this->addImport(DateTimeImmutable::class);
4✔
400
                    if ($field->isNullable()) {
4✔
401
                        $optionalVar = $this->builder->ternary(
3✔
402
                            $this->builder->notEquals($optionalResponseItems[$i], $this->builder->val(null)),
3✔
403
                            $this->builder->new('DateTimeImmutable', [$optionalResponseItems[$i]]),
3✔
404
                            $this->builder->val(null)
3✔
405
                        );
406
                    } else {
407
                        $optionalVar = $this->builder->new('DateTimeImmutable', [$optionalResponseItems[$i]]);
4✔
408
                    }
409
                } elseif ($field->isEnum() && $this->phpVersion->isEnumSupported()) {
16✔
410
                    $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $field->getPhpClassName()));
1✔
411
                    $optionalVar = $this->builder->staticCall($field->getPhpClassName(), 'from', [$optionalResponseItems[$i]]);
1✔
412
                } else {
413
                    $optionalVar = $optionalResponseItems[$i];
16✔
414
                }
415

416
                if ($root->hasOneOf() || $root->hasAnyOf()) {
16✔
417
                    $tryStatements = [
9✔
418
                        $this->builder->expr(
9✔
419
                            $this->builder->methodCall(
9✔
420
                                $schemaVar,
421
                                $this->getSetMethodName($field),
9✔
422
                                [$optionalVar]
9✔
423
                            )
424
                        ),
425
                    ];
426

427
                    $tryStatements[] = $this->builder->expr($this->builder->assign(
9✔
428
                        $this->builder->var('matches'),
9✔
429
                        $this->builder->operation($this->builder->var('matches'), '+', $this->builder->val(1))
9✔
430
                    ));
431

432
                    $this->addImport(UnexpectedResponseBodyException::class);
9✔
433
                    $catchStatement = $this->builder->catch(
9✔
434
                        [$this->builder->className('UnexpectedResponseBodyException')],
9✔
435
                        $this->builder->var('exception'),
9✔
436
                        []
9✔
437
                    );
438
                    $tryCatchStatements[] = $this->builder->tryCatch($tryStatements, [$catchStatement]);
9✔
439
                } else {
440
                    $ifCondition = $field->isNullable()
16✔
441
                        ? $this->builder->funcCall('array_key_exists', [$field->getName(), $payloadVariable])
4✔
442
                        : $this->builder->funcCall('isset', [$optionalResponseItems[$i]]);
16✔
443

444
                    $ifStmt = $this->builder->expr(
16✔
445
                        $this->builder->methodCall(
16✔
446
                            $schemaVar,
447
                            $this->getSetMethodName($field),
16✔
448
                            [$optionalVar]
16✔
449
                        )
450
                    );
451

452
                    $statements[] = $this->builder->if($ifCondition, [$ifStmt]);
16✔
453
                }
454
            }
455

456
            if ($root->hasOneOf() || $root->hasAnyOf()) {
16✔
457
                if ($root->getDiscriminator()) {
9✔
458
                    $ifCondition = $this->builder->funcCall('array_key_exists', [
3✔
459
                        /** @phpstan-ignore-next-line */
460
                        $root->getDiscriminator()->propertyName,
3✔
461
                        $payloadVariable,
3✔
462
                    ]);
463

464
                    $payloadDiscriminator = $this->builder->getArrayItem(
3✔
465
                        $payloadVariable,
466
                        /** @phpstan-ignore-next-line */
467
                        $this->builder->val($root->getDiscriminator()->propertyName)
3✔
468
                    );
469

470
                    $assignMethodName = $this->builder->expr(
3✔
471
                        $this->builder->assign(
3✔
472
                            $this->builder->var('methodName'),
3✔
473
                            $this->builder->concat(
3✔
474
                                $this->builder->val('set'),
3✔
475
                                $this->builder->funcCall('ucfirst', [$payloadDiscriminator])
3✔
476
                            )
477
                        )
478
                    );
479

480
                    $assignMapperName = $this->builder->expr(
3✔
481
                        $this->builder->assign(
3✔
482
                            $this->builder->var('mapperName'),
3✔
483
                            $this->builder->concat(
3✔
484
                                $payloadDiscriminator,
485
                                $this->builder->val('Mapper')
3✔
486
                            )
487
                        )
488
                    );
489

490
                    $schemaMethodCall = $this->builder->expr(
3✔
491
                        $this->builder->methodCall(
3✔
492
                            $this->builder->var('schema'),
3✔
493
                            '$methodName',
3✔
494
                            [
495
                                $this->builder->methodCall(
3✔
496
                                    $this->builder->localPropertyFetch('$mapperName'),
3✔
497
                                    'toSchema',
3✔
498
                                    [$payloadVariable]
3✔
499
                                ),
500
                            ]
501
                        )
502
                    );
503

504
                    $statements[] = $this->builder->if($ifCondition, [$assignMethodName, $assignMapperName, $schemaMethodCall]);
3✔
505
                } else {
506
                    $statements[] = $this->builder->assign($matchesVar, $this->builder->val(0));
6✔
507

508
                    $statements = [...$statements, ...$tryCatchStatements];
6✔
509

510
                    if ($root->hasAnyOf()) {
6✔
511
                        $statements[] = $this->builder->if(
3✔
512
                            $this->builder->equals($matchesVar, $this->builder->val(0)),
3✔
513
                            [
514
                                $this->builder->throw(
3✔
515
                                    'UnexpectedResponseBodyException'
3✔
516
                                ),
517
                            ]
518
                        );
519
                    }
520
                    if ($root->hasOneOf()) {
6✔
521
                        $statements[] = $this->builder->if(
3✔
522
                            $this->builder->notEquals($matchesVar, $this->builder->val(1)),
3✔
523
                            [
524
                                $this->builder->throw(
3✔
525
                                    'UnexpectedResponseBodyException'
3✔
526
                                ),
527
                            ]
528
                        );
529
                    }
530
                    $this->mapMethodThrownExceptions['UnexpectedResponseBodyException'] = true;
6✔
531
                }
532
            }
533

534
            if (!$this->hasComposite($optionalFields)) {
16✔
535
                $this->addImport(UnexpectedResponseBodyException::class);
16✔
536
                $statements[] = $this->builder->if(
16✔
537
                    $this->builder->funcCall('empty', [$this->builder->methodCall($schemaVar, 'toArray')]),
16✔
538
                    [
539
                        $this->builder->throw(
16✔
540
                            'UnexpectedResponseBodyException'
16✔
541
                        ),
542
                    ]
543
                );
544
                $this->mapMethodThrownExceptions['UnexpectedResponseBodyException'] = true;
16✔
545
            }
546

547
            $statements[] = $this->builder->return($schemaVar);
16✔
548
        } else {
549
            $statements[] = $this->builder->return($schemaInit);
12✔
550
        }
551

552
        return $statements;
19✔
553
    }
554
}
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