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

LibreSign / libresign / 21364798873

26 Jan 2026 04:11PM UTC coverage: 46.283%. First build
21364798873

Pull #6587

github

web-flow
Merge 3ece2e19f 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%)

4.99 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<string, mixed>> */
38
        private array $signRequestErrors = [];
39
        /** @var array<string, array<string, mixed>> */
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

498
                return $mapped;
2✔
499
        }
500

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

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

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

521
                return $mapped;
5✔
522
        }
523

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

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

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

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