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

LibreSign / libresign / 21375555321

26 Jan 2026 09:59PM UTC coverage: 46.283%. First build
21375555321

Pull #6587

github

web-flow
Merge 3c9e3b221 into 507d36d8b
Pull Request #6587: feat: improve async signing error handling

260 of 308 new or added lines in 10 files covered. (84.42%)

7757 of 16760 relevant lines covered (46.28%)

5.0 hits per line

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

83.21
/lib/Service/SignRequest/ProgressService.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Service\SignRequest;
10

11
use OCA\Libresign\Db\File as FileEntity;
12
use OCA\Libresign\Db\FileMapper;
13
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
14
use OCA\Libresign\Db\SignRequestMapper;
15
use OCA\Libresign\Enum\FileStatus;
16
use OCA\Libresign\Enum\SignRequestStatus;
17
use OCP\AppFramework\Db\DoesNotExistException;
18
use OCP\ICache;
19
use OCP\ICacheFactory;
20

21
/**
22
 * Service for calculating and managing sign request progress
23
 *
24
 * This service encapsulates the business logic for:
25
 * - Calculating progress for specific sign requests
26
 * - Handling different file types (simple files, envelopes)
27
 * - Polling for status changes
28
 * - Building status responses
29
 *
30
 * Testable unit that can be tested independently of HTTP concerns
31
 */
32
class ProgressService {
33
        private ICache $cache;
34
        public const ERROR_KEY_PREFIX = 'libresign_sign_request_error_';
35
        public const FILE_ERROR_KEY_PREFIX = 'libresign_file_error_';
36
        public const ERROR_CACHE_TTL = 300;
37
        /** @var array<string, array> */
38
        private array $signRequestErrors = [];
39
        /** @var array<string, array> */
40
        private array $fileErrors = [];
41

42
        public function __construct(
43
                private FileMapper $fileMapper,
44
                ICacheFactory $cacheFactory,
45
                private SignRequestMapper $signRequestMapper,
46
                private StatusCacheService $statusCacheService,
47
        ) {
48
                $this->cache = $cacheFactory->createDistributed('libresign_progress');
28✔
49
        }
50

51
        /**
52
         * Poll for status change of a sign request
53
         *
54
         * Waits up to the specified timeout for the status to change by checking cache
55
         *
56
         * @return int The current status (changed or original if timeout reached)
57
         */
58
        public function pollForStatusChange(string $uuid, int $initialStatus, int $timeout = 30, int $intervalSeconds = 1): int {
59
                return $this->pollForStatusChangeInternal($uuid, [], $initialStatus, $timeout, $intervalSeconds);
2✔
60
        }
61

62
        public function pollForStatusOrErrorChange(
63
                FileEntity $file,
64
                SignRequestEntity $signRequest,
65
                int $initialStatus,
66
                int $timeout = 30,
67
                int $intervalSeconds = 1,
68
        ): int {
69
                $statusUuid = $file->getUuid();
3✔
70
                if ($file->getNodeType() !== 'envelope') {
3✔
71
                        return $this->pollForStatusChangeInternal(
2✔
72
                                $statusUuid,
2✔
73
                                [$signRequest->getUuid()],
2✔
74
                                $initialStatus,
2✔
75
                                $timeout,
2✔
76
                                $intervalSeconds,
2✔
77
                        );
2✔
78
                }
79

80
                $signRequestUuids = [$signRequest->getUuid()];
1✔
81
                $childSignRequests = $this->signRequestMapper
1✔
82
                        ->getByEnvelopeChildrenAndIdentifyMethod($file->getId(), $signRequest->getId());
1✔
83
                foreach ($childSignRequests as $childSignRequest) {
1✔
84
                        $childUuid = $childSignRequest->getUuid();
1✔
85
                        if ($childUuid !== '') {
1✔
86
                                $signRequestUuids[] = $childUuid;
1✔
87
                        }
88
                }
89

90
                return $this->pollForStatusChangeInternal(
1✔
91
                        $statusUuid,
1✔
92
                        $signRequestUuids,
1✔
93
                        $initialStatus,
1✔
94
                        $timeout,
1✔
95
                        $intervalSeconds,
1✔
96
                );
1✔
97
        }
98

99
        private function pollForStatusChangeInternal(
100
                string $statusUuid,
101
                array $errorUuids,
102
                int $initialStatus,
103
                int $timeout,
104
                int $intervalSeconds,
105
        ): int {
106
                $cachedStatus = $this->statusCacheService->getStatus($statusUuid);
5✔
107
                $interval = max(1, $intervalSeconds);
5✔
108

109
                for ($elapsed = 0; $elapsed < $timeout; $elapsed += $interval) {
5✔
110
                        if (!empty($errorUuids) && $this->hasAnySignRequestError($errorUuids)) {
5✔
111
                                return $initialStatus;
2✔
112
                        }
113

114
                        $newCachedStatus = $this->statusCacheService->getStatus($statusUuid);
3✔
115
                        if ($newCachedStatus !== $cachedStatus && $newCachedStatus !== false) {
3✔
116
                                return (int)$newCachedStatus;
1✔
117
                        }
118

119
                        if ($intervalSeconds > 0) {
3✔
120
                                sleep($intervalSeconds);
×
121
                        }
122
                }
123

124
                return $initialStatus;
2✔
125
        }
126

127
        public function setSignRequestError(string $uuid, array $error, int $ttl = self::ERROR_CACHE_TTL): void {
128
                $this->signRequestErrors[$uuid] = $error;
3✔
129
                $this->cache->set(self::ERROR_KEY_PREFIX . $uuid, $error, $ttl);
3✔
130
                $this->storeSignRequestErrorInMetadata($uuid, $error);
3✔
131
        }
132

133
        public function getSignRequestError(string $uuid): ?array {
134
                $error = $this->cache->get(self::ERROR_KEY_PREFIX . $uuid);
8✔
135
                if ($error === false || $error === null) {
8✔
136
                        return $this->signRequestErrors[$uuid]
6✔
137
                                ?? $this->getSignRequestErrorFromMetadata($uuid);
6✔
138
                }
139
                return is_array($error) ? $error : ['message' => (string)$error];
3✔
140
        }
141

142
        public function clearSignRequestError(string $uuid): void {
143
                unset($this->signRequestErrors[$uuid]);
1✔
144
                $this->cache->remove(self::ERROR_KEY_PREFIX . $uuid);
1✔
145
                $this->clearSignRequestErrorInMetadata($uuid);
1✔
146
        }
147

148
        private function hasSignRequestError(string $uuid): bool {
149
                $error = $this->getSignRequestError($uuid);
3✔
150
                return $error !== null;
3✔
151
        }
152

153
        private function hasAnySignRequestError(array $uuids): bool {
154
                foreach ($uuids as $uuid) {
3✔
155
                        if ($uuid !== '' && $this->hasSignRequestError($uuid)) {
3✔
156
                                return true;
2✔
157
                        }
158
                }
159
                return false;
1✔
160
        }
161

162
        public function setFileError(string $uuid, int $fileId, array $error, int $ttl = self::ERROR_CACHE_TTL): void {
163
                $key = $this->buildFileErrorKey($uuid, $fileId);
7✔
164
                $this->fileErrors[$key] = $error;
7✔
165
                $this->cache->set($key, $error, $ttl);
7✔
166
                $this->storeFileErrorInMetadata($uuid, $fileId, $error);
7✔
167
        }
168

169
        public function getFileError(string $uuid, int $fileId): ?array {
170
                $key = $this->buildFileErrorKey($uuid, $fileId);
19✔
171
                $error = $this->cache->get($key);
19✔
172
                if ($error === false || $error === null) {
19✔
173
                        return $this->fileErrors[$key]
19✔
174
                                ?? $this->getFileErrorFromMetadata($uuid, $fileId);
19✔
175
                }
NEW
176
                return is_array($error) ? $error : ['message' => (string)$error];
×
177
        }
178

179
        public function clearFileError(string $uuid, int $fileId): void {
180
                $key = $this->buildFileErrorKey($uuid, $fileId);
1✔
181
                unset($this->fileErrors[$key]);
1✔
182
                $this->cache->remove($key);
1✔
183
                $this->clearFileErrorInMetadata($uuid, $fileId);
1✔
184
        }
185

186
        private function buildFileErrorKey(string $uuid, int $fileId): string {
187
                return self::FILE_ERROR_KEY_PREFIX . $uuid . '_' . $fileId;
19✔
188
        }
189

190
        private function storeSignRequestErrorInMetadata(string $uuid, array $error): void {
191
                if ($uuid === '') {
3✔
NEW
192
                        return;
×
193
                }
194

195
                try {
196
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
3✔
NEW
197
                } catch (DoesNotExistException) {
×
NEW
198
                        return;
×
199
                }
200
                if (!$signRequest instanceof SignRequestEntity) {
3✔
NEW
201
                        return;
×
202
                }
203

204
                $metadata = $signRequest->getMetadata() ?? [];
3✔
205
                $metadata['libresign_error'] = $error;
3✔
206
                $signRequest->setMetadata($metadata);
3✔
207
                $this->signRequestMapper->update($signRequest);
3✔
208
        }
209

210
        private function getSignRequestErrorFromMetadata(string $uuid): ?array {
211
                if ($uuid === '') {
5✔
NEW
212
                        return null;
×
213
                }
214

215
                try {
216
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
5✔
217
                } catch (DoesNotExistException) {
1✔
218
                        return null;
1✔
219
                }
220
                if (!$signRequest instanceof SignRequestEntity) {
4✔
NEW
221
                        return null;
×
222
                }
223

224
                $metadata = $signRequest->getMetadata() ?? [];
4✔
225
                $error = $metadata['libresign_error'] ?? null;
4✔
226
                return is_array($error) ? $error : null;
4✔
227
        }
228

229
        private function clearSignRequestErrorInMetadata(string $uuid): void {
230
                if ($uuid === '') {
1✔
NEW
231
                        return;
×
232
                }
233

234
                try {
235
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
1✔
NEW
236
                } catch (DoesNotExistException) {
×
NEW
237
                        return;
×
238
                }
239
                if (!$signRequest instanceof SignRequestEntity) {
1✔
NEW
240
                        return;
×
241
                }
242

243
                $metadata = $signRequest->getMetadata() ?? [];
1✔
244
                if (!array_key_exists('libresign_error', $metadata)) {
1✔
245
                        return;
1✔
246
                }
247

NEW
248
                unset($metadata['libresign_error']);
×
NEW
249
                $signRequest->setMetadata($metadata);
×
NEW
250
                $this->signRequestMapper->update($signRequest);
×
251
        }
252

253
        private function storeFileErrorInMetadata(string $uuid, int $fileId, array $error): void {
254
                if ($uuid === '') {
7✔
NEW
255
                        return;
×
256
                }
257

258
                try {
259
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
7✔
NEW
260
                } catch (DoesNotExistException) {
×
NEW
261
                        return;
×
262
                }
263
                if (!$signRequest instanceof SignRequestEntity) {
7✔
NEW
264
                        return;
×
265
                }
266

267
                $metadata = $signRequest->getMetadata() ?? [];
7✔
268
                $fileErrors = $metadata['libresign_file_errors'] ?? [];
7✔
269
                if (!is_array($fileErrors)) {
7✔
NEW
270
                        $fileErrors = [];
×
271
                }
272

273
                $fileErrors[$fileId] = $error;
7✔
274
                $metadata['libresign_file_errors'] = $fileErrors;
7✔
275
                $signRequest->setMetadata($metadata);
7✔
276
                $this->signRequestMapper->update($signRequest);
7✔
277
        }
278

279
        private function getFileErrorFromMetadata(string $uuid, int $fileId): ?array {
280
                if ($uuid === '') {
14✔
281
                        return null;
7✔
282
                }
283

284
                try {
285
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
7✔
NEW
286
                } catch (DoesNotExistException) {
×
NEW
287
                        return null;
×
288
                }
289
                if (!$signRequest instanceof SignRequestEntity) {
7✔
NEW
290
                        return null;
×
291
                }
292

293
                $metadata = $signRequest->getMetadata() ?? [];
7✔
294
                $fileErrors = $metadata['libresign_file_errors'] ?? null;
7✔
295
                if (!is_array($fileErrors)) {
7✔
296
                        return null;
5✔
297
                }
298

299
                $error = $fileErrors[$fileId] ?? null;
2✔
300
                return is_array($error) ? $error : null;
2✔
301
        }
302

303
        private function clearFileErrorInMetadata(string $uuid, int $fileId): void {
304
                if ($uuid === '') {
1✔
NEW
305
                        return;
×
306
                }
307

308
                try {
309
                        $signRequest = $this->signRequestMapper->getByUuidUncached($uuid);
1✔
NEW
310
                } catch (DoesNotExistException) {
×
NEW
311
                        return;
×
312
                }
313
                if (!$signRequest instanceof SignRequestEntity) {
1✔
NEW
314
                        return;
×
315
                }
316

317
                $metadata = $signRequest->getMetadata() ?? [];
1✔
318
                $fileErrors = $metadata['libresign_file_errors'] ?? null;
1✔
319
                if (!is_array($fileErrors) || !array_key_exists($fileId, $fileErrors)) {
1✔
320
                        return;
1✔
321
                }
322

NEW
323
                unset($fileErrors[$fileId]);
×
NEW
324
                if (empty($fileErrors)) {
×
NEW
325
                        unset($metadata['libresign_file_errors']);
×
326
                } else {
NEW
327
                        $metadata['libresign_file_errors'] = $fileErrors;
×
328
                }
NEW
329
                $signRequest->setMetadata($metadata);
×
NEW
330
                $this->signRequestMapper->update($signRequest);
×
331
        }
332

333
        /**
334
         * Get progress for a specific sign request
335
         *
336
         * Returns progress data tailored to the specific sign request,
337
         * not the global file status
338
         *
339
         * @return array<string, mixed> Progress data with structure: {total, signed, pending, files?, signers?}
340
         */
341
        public function getSignRequestProgress(FileEntity $file, SignRequestEntity $signRequest): array {
342
                return match (true) {
343
                        $file->getNodeType() === 'envelope' => $this->getEnvelopeProgressForSignRequest($file, $signRequest),
2✔
344
                        !$file->getParentFileId() => $this->getSingleFileProgressForSignRequest($file, $signRequest),
2✔
345
                        default => $this->getFileProgressForSignRequest($file, $signRequest),
2✔
346
                };
347
        }
348

349
        public function getStatusCodeForSignRequest(FileEntity $file, SignRequestEntity $signRequest): int {
350
                return $this->getSignRequestStatusCode($file, $signRequest);
1✔
351
        }
352

353
        public function isProgressComplete(array $progress): bool {
354
                $total = (int)($progress['total'] ?? 0);
1✔
355
                if ($total <= 0) {
1✔
356
                        return false;
×
357
                }
358
                $signed = (int)($progress['signed'] ?? 0);
1✔
359
                $pending = (int)($progress['pending'] ?? 0);
1✔
360
                $inProgress = (int)($progress['inProgress'] ?? 0);
1✔
361
                $errors = (int)($progress['errors'] ?? 0);
1✔
362
                return ($signed + $errors) >= $total && $pending <= 0 && $inProgress <= 0;
1✔
363
        }
364

365
        /**
366
         * Get progress for a sign request on a single file
367
         *
368
         * Returns counts relative to the sign request status
369
         *
370
         * @return array<string, mixed>
371
         */
372
        public function getSingleFileProgressForSignRequest(FileEntity $file, SignRequestEntity $signRequest): array {
373
                $statusCode = $this->getSignRequestStatusCode($file, $signRequest);
6✔
374
                $isSigned = $statusCode === FileStatus::SIGNED->value;
6✔
375
                $isInProgress = $statusCode === FileStatus::SIGNING_IN_PROGRESS->value;
6✔
376
                $fileError = $this->getFileError($signRequest->getUuid(), $file->getId());
6✔
377
                $hasError = $fileError !== null;
6✔
378

379
                return [
6✔
380
                        'total' => 1,
6✔
381
                        'signed' => $isSigned ? 1 : 0,
6✔
382
                        'inProgress' => $hasError ? 0 : ($isInProgress ? 1 : 0),
6✔
383
                        'errors' => $hasError ? 1 : 0,
6✔
384
                        'pending' => $hasError || $isSigned || $isInProgress ? 0 : 1,
6✔
385
                        'files' => [
6✔
386
                                array_merge(
6✔
387
                                        [
6✔
388
                                                'id' => $file->getId(),
6✔
389
                                                'name' => $file->getName(),
6✔
390
                                                'status' => $statusCode,
6✔
391
                                                'statusText' => $this->fileMapper->getTextOfStatus($statusCode),
6✔
392
                                        ],
6✔
393
                                        $hasError ? ['error' => $fileError] : []
6✔
394
                                )
6✔
395
                        ],
6✔
396
                ];
6✔
397
        }
398

399
        /**
400
         * Get progress for a sign request on an envelope
401
         *
402
         * Returns progress for all files in the envelope relative to this signer
403
         *
404
         * @return array<string, mixed>
405
         */
406
        public function getEnvelopeProgressForSignRequest(FileEntity $envelope, SignRequestEntity $signRequest): array {
407
                $children = $this->fileMapper->getChildrenFiles($envelope->getId());
3✔
408
                if (empty($children)) {
3✔
409
                        $children = [$envelope];
×
410
                }
411

412
                $childSignRequests = $this->signRequestMapper
3✔
413
                        ->getByEnvelopeChildrenAndIdentifyMethod($envelope->getId(), $signRequest->getId());
3✔
414
                $childSignRequestsByFileId = [];
3✔
415
                foreach ($childSignRequests as $childSignRequest) {
3✔
NEW
416
                        $childSignRequestsByFileId[$childSignRequest->getFileId()] = $childSignRequest;
×
417
                }
418

419
                $files = array_map(function (FileEntity $child) use ($signRequest, $childSignRequestsByFileId): array {
3✔
420
                        $childSignRequest = $childSignRequestsByFileId[$child->getId()] ?? null;
3✔
421
                        return $this->mapSignRequestFileProgressWithContext($child, $signRequest, $childSignRequest);
3✔
422
                }, $children);
3✔
423
                $total = count($files);
3✔
424
                $signed = count(array_filter($files, fn (array $file) => $file['status'] === FileStatus::SIGNED->value));
3✔
425
                $inProgress = count(array_filter($files, fn (array $file) => $file['status'] === FileStatus::SIGNING_IN_PROGRESS->value));
3✔
426
                $errors = count(array_filter($files, fn (array $file) => !empty($file['error'])));
3✔
427
                $pending = max(0, $total - $signed - $inProgress - $errors);
3✔
428

429
                return [
3✔
430
                        'total' => $total,
3✔
431
                        'signed' => $signed,
3✔
432
                        'inProgress' => $inProgress,
3✔
433
                        'errors' => $errors,
3✔
434
                        'pending' => $pending,
3✔
435
                        'files' => $files,
3✔
436
                ];
3✔
437
        }
438

439
        /**
440
         * Get progress for a sign request on a child file in an envelope
441
         *
442
         * @return array<string, mixed>
443
         */
444
        public function getFileProgressForSignRequest(FileEntity $file, SignRequestEntity $signRequest): array {
445
                $statusCode = $this->getSignRequestStatusCode($file, $signRequest);
1✔
446
                $isSigned = $statusCode === FileStatus::SIGNED->value;
1✔
447
                $isInProgress = $statusCode === FileStatus::SIGNING_IN_PROGRESS->value;
1✔
448
                $fileError = $this->getFileError($signRequest->getUuid(), $file->getId());
1✔
449
                $hasError = $fileError !== null;
1✔
450

451
                return [
1✔
452
                        'total' => 1,
1✔
453
                        'signed' => $isSigned ? 1 : 0,
1✔
454
                        'inProgress' => $hasError ? 0 : ($isInProgress ? 1 : 0),
1✔
455
                        'errors' => $hasError ? 1 : 0,
1✔
456
                        'pending' => $hasError || $isSigned || $isInProgress ? 0 : 1,
1✔
457
                        'signers' => [
1✔
458
                                [
1✔
459
                                        'id' => $signRequest->getId(),
1✔
460
                                        'displayName' => $signRequest->getDisplayName(),
1✔
461
                                        'signed' => $signRequest->getSigned()?->format('c'),
1✔
462
                                        'status' => $statusCode,
1✔
463
                                ]
1✔
464
                        ],
1✔
465
                ];
1✔
466
        }
467

468
        /**
469
         * Map file progress data
470
         *
471
         * @return array<string, mixed>
472
         */
473
        private function mapFileProgress(FileEntity $file): array {
474
                return [
×
475
                        'id' => $file->getId(),
×
476
                        'name' => $file->getName(),
×
477
                        'status' => $file->getStatus(),
×
478
                        'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()),
×
479
                ];
×
480
        }
481

482
        private function mapSignRequestFileProgress(FileEntity $file, SignRequestEntity $signRequest): array {
483
                $statusCode = $this->getSignRequestStatusCode($file, $signRequest);
2✔
484
                $error = $this->getFileError($signRequest->getUuid(), $file->getId());
2✔
485

486
                $mapped = [
2✔
487
                        'id' => $file->getId(),
2✔
488
                        'name' => $file->getName(),
2✔
489
                        'status' => $statusCode,
2✔
490
                        'statusText' => $this->fileMapper->getTextOfStatus($statusCode),
2✔
491
                ];
2✔
492

493
                if ($error !== null) {
2✔
494
                        $mapped['error'] = $error;
1✔
495
                }
496

497
                return $mapped;
2✔
498
        }
499

500
        private function mapSignRequestFileProgressWithContext(FileEntity $file, SignRequestEntity $defaultSignRequest, ?SignRequestEntity $childSignRequest): array {
501
                $effectiveSignRequest = $childSignRequest ?? $defaultSignRequest;
5✔
502
                $statusCode = $this->getSignRequestStatusCode($file, $effectiveSignRequest);
5✔
503
                $errorUuid = $childSignRequest?->getUuid() ?? $defaultSignRequest->getUuid();
5✔
504
                $error = $this->getFileError($errorUuid, $file->getId());
5✔
505
                if ($error === null) {
5✔
506
                        $error = $this->findFileErrorAcrossSignRequests($file->getId());
3✔
507
                }
508

509
                $mapped = [
5✔
510
                        'id' => $file->getId(),
5✔
511
                        'name' => $file->getName(),
5✔
512
                        'status' => $statusCode,
5✔
513
                        'statusText' => $this->fileMapper->getTextOfStatus($statusCode),
5✔
514
                ];
5✔
515

516
                if ($error !== null) {
5✔
517
                        $mapped['error'] = $error;
3✔
518
                }
519

520
                return $mapped;
5✔
521
        }
522

523
        private function findFileErrorAcrossSignRequests(int $fileId): ?array {
524
                $signRequests = $this->signRequestMapper->getByFileId($fileId);
3✔
525
                foreach ($signRequests as $signRequest) {
3✔
NEW
526
                        $error = $this->getFileError($signRequest->getUuid(), $fileId);
×
NEW
527
                        if ($error !== null) {
×
NEW
528
                                return $error;
×
529
                        }
530
                }
531
                return null;
3✔
532
        }
533

534
        private function getSignRequestStatusCode(FileEntity $file, SignRequestEntity $signRequest): int {
535
                if ($file->getStatus() === FileStatus::SIGNING_IN_PROGRESS->value) {
14✔
536
                        return FileStatus::SIGNING_IN_PROGRESS->value;
3✔
537
                }
538

539
                if ($signRequest->getSigned() !== null) {
12✔
540
                        return FileStatus::SIGNED->value;
2✔
541
                }
542

543
                return match ($signRequest->getStatusEnum()) {
10✔
544
                        SignRequestStatus::DRAFT => FileStatus::DRAFT->value,
10✔
545
                        SignRequestStatus::ABLE_TO_SIGN => FileStatus::ABLE_TO_SIGN->value,
×
546
                        default => $file->getStatus(),
10✔
547
                };
10✔
548
        }
549
}
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