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

LibreSign / libresign / 20179022714

12 Dec 2025 08:27PM UTC coverage: 43.981%. First build
20179022714

Pull #6169

github

web-flow
Merge e97580bc2 into c05b15a8e
Pull Request #6169: feat: file level signature flow

36 of 42 new or added lines in 8 files covered. (85.71%)

5816 of 13224 relevant lines covered (43.98%)

5.13 hits per line

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

76.11
/lib/Service/RequestSignatureService.php
1
<?php
2

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

9
namespace OCA\Libresign\Service;
10

11
use OCA\Libresign\AppInfo\Application;
12
use OCA\Libresign\Db\File as FileEntity;
13
use OCA\Libresign\Db\FileElementMapper;
14
use OCA\Libresign\Db\FileMapper;
15
use OCA\Libresign\Db\IdentifyMethodMapper;
16
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
17
use OCA\Libresign\Db\SignRequestMapper;
18
use OCA\Libresign\Enum\SignatureFlow;
19
use OCA\Libresign\Handler\DocMdpHandler;
20
use OCA\Libresign\Helper\ValidateHelper;
21
use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
22
use OCP\AppFramework\Db\DoesNotExistException;
23
use OCP\Files\IMimeTypeDetector;
24
use OCP\Files\Node;
25
use OCP\Http\Client\IClientService;
26
use OCP\IAppConfig;
27
use OCP\IL10N;
28
use OCP\IUser;
29
use OCP\IUserManager;
30
use Psr\Log\LoggerInterface;
31
use Sabre\DAV\UUIDUtil;
32

33
class RequestSignatureService {
34
        use TFile;
35

36
        public function __construct(
37
                protected IL10N $l10n,
38
                protected IdentifyMethodService $identifyMethod,
39
                protected SignRequestMapper $signRequestMapper,
40
                protected IUserManager $userManager,
41
                protected FileMapper $fileMapper,
42
                protected IdentifyMethodMapper $identifyMethodMapper,
43
                protected PdfParserService $pdfParserService,
44
                protected FileElementService $fileElementService,
45
                protected FileElementMapper $fileElementMapper,
46
                protected FolderService $folderService,
47
                protected IMimeTypeDetector $mimeTypeDetector,
48
                protected ValidateHelper $validateHelper,
49
                protected IClientService $client,
50
                protected DocMdpHandler $docMdpHandler,
51
                protected LoggerInterface $logger,
52
                protected SequentialSigningService $sequentialSigningService,
53
                protected IAppConfig $appConfig,
54
        ) {
55
        }
54✔
56

57
        public function save(array $data): FileEntity {
58
                $file = $this->saveFile($data);
14✔
59
                $this->saveVisibleElements($data, $file);
14✔
60
                if (!isset($data['status'])) {
14✔
61
                        $data['status'] = $file->getStatus();
12✔
62
                }
63
                $this->sequentialSigningService->setFile($file);
14✔
64
                $this->associateToSigners($data, $file->getId());
14✔
65
                return $file;
14✔
66
        }
67

68
        /**
69
         * Save file data
70
         *
71
         * @param array{?userManager: IUser, ?signRequest: SignRequest, name: string, callback: string, uuid?: ?string, status: int, file?: array{fileId?: int, fileNode?: Node}} $data
72
         */
73
        public function saveFile(array $data): FileEntity {
74
                if (!empty($data['uuid'])) {
15✔
75
                        $file = $this->fileMapper->getByUuid($data['uuid']);
1✔
76
                        return $this->updateStatus($file, $data['status'] ?? 0);
1✔
77
                }
78
                $fileId = null;
15✔
79
                if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) {
15✔
80
                        $fileId = $data['file']['fileNode']->getId();
1✔
81
                } elseif (!empty($data['file']['fileId'])) {
14✔
82
                        $fileId = $data['file']['fileId'];
×
83
                }
84
                if (!is_null($fileId)) {
15✔
85
                        try {
86
                                $file = $this->fileMapper->getByFileId($fileId);
1✔
87
                                return $this->updateStatus($file, $data['status'] ?? 0);
×
88
                        } catch (\Throwable) {
1✔
89
                        }
90
                }
91

92
                $node = $this->getNodeFromData($data);
15✔
93

94
                $file = new FileEntity();
15✔
95
                $file->setNodeId($node->getId());
15✔
96
                if ($data['userManager'] instanceof IUser) {
15✔
97
                        $file->setUserId($data['userManager']->getUID());
15✔
98
                } elseif ($data['signRequest'] instanceof SignRequestEntity) {
×
99
                        $file->setSignRequestId($data['signRequest']->getId());
×
100
                }
101
                $file->setUuid(UUIDUtil::getUUID());
15✔
102
                $file->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
15✔
103
                $metadata = $this->getFileMetadata($node);
15✔
104
                $file->setName($this->removeExtensionFromName($data['name'], $metadata));
15✔
105
                $file->setMetadata($metadata);
15✔
106
                if (!empty($data['callback'])) {
15✔
107
                        $file->setCallback($data['callback']);
×
108
                }
109
                if (isset($data['status'])) {
15✔
110
                        $file->setStatus($data['status']);
2✔
111
                } else {
112
                        $file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN);
13✔
113
                }
114

115
                if (isset($data['signatureFlow']) && is_string($data['signatureFlow'])) {
15✔
116
                        try {
NEW
117
                                $signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($data['signatureFlow']);
×
NEW
118
                                $file->setSignatureFlowEnum($signatureFlow);
×
NEW
119
                        } catch (\ValueError) {
×
NEW
120
                                $this->setSignatureFlowFromGlobalConfig($file);
×
121
                        }
122
                } else {
123
                        $this->setSignatureFlowFromGlobalConfig($file);
15✔
124
                }
125

126
                $this->fileMapper->insert($file);
15✔
127
                return $file;
15✔
128
        }
129

130
        private function setSignatureFlowFromGlobalConfig(FileEntity $file): void {
131
                $globalFlowValue = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::PARALLEL->value);
15✔
132
                $globalFlow = SignatureFlow::from($globalFlowValue);
15✔
133
                $file->setSignatureFlowEnum($globalFlow);
15✔
134
        }
135

136
        private function updateStatus(FileEntity $file, int $status): FileEntity {
137
                if ($status > $file->getStatus()) {
1✔
138
                        $file->setStatus($status);
×
139
                        /** @var FileEntity */
140
                        return $this->fileMapper->update($file);
×
141
                }
142
                return $file;
1✔
143
        }
144

145
        private function getFileMetadata(\OCP\Files\Node $node): array {
146
                $metadata = [];
18✔
147
                if ($extension = strtolower($node->getExtension())) {
18✔
148
                        $metadata = [
17✔
149
                                'extension' => $extension,
17✔
150
                        ];
17✔
151
                        if ($metadata['extension'] === 'pdf') {
17✔
152
                                $metadata = array_merge(
16✔
153
                                        $metadata,
16✔
154
                                        $this->pdfParserService
16✔
155
                                                ->setFile($node)
16✔
156
                                                ->getPageDimensions()
16✔
157
                                );
16✔
158
                        }
159
                }
160
                return $metadata;
18✔
161
        }
162

163
        private function removeExtensionFromName(string $name, array $metadata): string {
164
                if (!isset($metadata['extension'])) {
15✔
165
                        return $name;
×
166
                }
167
                $extensionPattern = '/\.' . preg_quote($metadata['extension'], '/') . '$/i';
15✔
168
                $result = preg_replace($extensionPattern, '', $name);
15✔
169
                return $result ?? $name;
15✔
170
        }
171

172
        private function deleteIdentifyMethodIfNotExits(array $users, int $fileId): void {
173
                $file = $this->fileMapper->getById($fileId);
13✔
174
                $signRequests = $this->signRequestMapper->getByFileId($fileId);
13✔
175
                foreach ($signRequests as $key => $signRequest) {
13✔
176
                        $identifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequest->getId());
1✔
177
                        if (empty($identifyMethods)) {
1✔
178
                                $this->unassociateToUser($file->getNodeId(), $signRequest->getId());
×
179
                                continue;
×
180
                        }
181
                        foreach ($identifyMethods as $methodName => $list) {
1✔
182
                                foreach ($list as $method) {
1✔
183
                                        $exists[$key]['identify'][$methodName] = $method->getEntity()->getIdentifierValue();
1✔
184
                                        if (!$this->identifyMethodExists($users, $method)) {
1✔
185
                                                $this->unassociateToUser($file->getNodeId(), $signRequest->getId());
1✔
186
                                                continue 3;
1✔
187
                                        }
188
                                }
189
                        }
190
                }
191
        }
192

193
        private function identifyMethodExists(array $users, IIdentifyMethod $identifyMethod): bool {
194
                foreach ($users as $user) {
1✔
195
                        if (!empty($user['identifyMethods'])) {
1✔
196
                                foreach ($user['identifyMethods'] as $data) {
×
197
                                        if ($identifyMethod->getEntity()->getIdentifierKey() !== $data['method']) {
×
198
                                                continue;
×
199
                                        }
200
                                        if ($identifyMethod->getEntity()->getIdentifierValue() === $data['value']) {
×
201
                                                return true;
×
202
                                        }
203
                                }
204
                        } else {
205
                                foreach ($user['identify'] as $method => $value) {
1✔
206
                                        if ($identifyMethod->getEntity()->getIdentifierKey() !== $method) {
1✔
207
                                                continue;
×
208
                                        }
209
                                        if ($identifyMethod->getEntity()->getIdentifierValue() === $value) {
1✔
210
                                                return true;
×
211
                                        }
212
                                }
213
                        }
214
                }
215
                return false;
1✔
216
        }
217

218
        /**
219
         * @return SignRequestEntity[]
220
         *
221
         * @psalm-return list<SignRequestEntity>
222
         */
223
        private function associateToSigners(array $data, int $fileId): array {
224
                $return = [];
14✔
225
                if (!empty($data['users'])) {
14✔
226
                        $this->deleteIdentifyMethodIfNotExits($data['users'], $fileId);
13✔
227

228
                        $this->sequentialSigningService->resetOrderCounter();
13✔
229
                        $fileStatus = $data['status'] ?? null;
13✔
230

231
                        foreach ($data['users'] as $user) {
13✔
232
                                $userProvidedOrder = isset($user['signingOrder']) ? (int)$user['signingOrder'] : null;
13✔
233
                                $signingOrder = $this->sequentialSigningService->determineSigningOrder($userProvidedOrder);
13✔
234
                                $signerStatus = $user['status'] ?? null;
13✔
235

236
                                if (isset($user['identifyMethods'])) {
13✔
237
                                        foreach ($user['identifyMethods'] as $identifyMethod) {
×
238
                                                $return[] = $this->associateToSigner(
×
239
                                                        identifyMethods: [
×
240
                                                                $identifyMethod['method'] => $identifyMethod['value'],
×
241
                                                        ],
×
242
                                                        displayName: $user['displayName'] ?? '',
×
243
                                                        description: $user['description'] ?? '',
×
244
                                                        notify: empty($user['notify']) && $this->isStatusAbleToNotify($fileStatus),
×
245
                                                        fileId: $fileId,
×
246
                                                        signingOrder: $signingOrder,
×
247
                                                        fileStatus: $fileStatus,
×
248
                                                        signerStatus: $signerStatus,
×
249
                                                );
×
250
                                        }
251
                                } else {
252
                                        $return[] = $this->associateToSigner(
13✔
253
                                                identifyMethods: $user['identify'],
13✔
254
                                                displayName: $user['displayName'] ?? '',
13✔
255
                                                description: $user['description'] ?? '',
13✔
256
                                                notify: empty($user['notify']) && $this->isStatusAbleToNotify($fileStatus),
13✔
257
                                                fileId: $fileId,
13✔
258
                                                signingOrder: $signingOrder,
13✔
259
                                                fileStatus: $fileStatus,
13✔
260
                                                signerStatus: $signerStatus,
13✔
261
                                        );
13✔
262
                                }
263
                        }
264
                }
265
                return $return;
14✔
266
        }
267

268
        private function isStatusAbleToNotify(?int $status): bool {
269
                return in_array($status, [
13✔
270
                        FileEntity::STATUS_ABLE_TO_SIGN,
13✔
271
                        FileEntity::STATUS_PARTIAL_SIGNED,
13✔
272
                ]);
13✔
273
        }
274

275
        private function associateToSigner(
276
                array $identifyMethods,
277
                string $displayName,
278
                string $description,
279
                bool $notify,
280
                int $fileId,
281
                int $signingOrder = 0,
282
                ?int $fileStatus = null,
283
                ?int $signerStatus = null,
284
        ): SignRequestEntity {
285
                $identifyMethodsIncances = $this->identifyMethod->getByUserData($identifyMethods);
13✔
286
                if (empty($identifyMethodsIncances)) {
13✔
287
                        throw new \Exception($this->l10n->t('Invalid identification method'));
×
288
                }
289
                $signRequest = $this->getSignRequestByIdentifyMethod(
13✔
290
                        current($identifyMethodsIncances),
13✔
291
                        $fileId
13✔
292
                );
13✔
293
                $displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsIncances, $displayName);
13✔
294
                $this->setDataToUser($signRequest, $displayName, $description, $fileId);
13✔
295

296
                $signRequest->setSigningOrder($signingOrder);
13✔
297

298
                $isNewSignRequest = !$signRequest->getId();
13✔
299
                $currentStatus = $signRequest->getStatusEnum();
13✔
300

301
                if ($isNewSignRequest || $currentStatus === \OCA\Libresign\Enum\SignRequestStatus::DRAFT) {
13✔
302
                        $desiredStatus = $this->determineInitialStatus($signingOrder, $fileStatus, $signerStatus, $currentStatus, $fileId);
13✔
303
                        $this->updateStatusIfAllowed($signRequest, $currentStatus, $desiredStatus, $isNewSignRequest);
13✔
304
                }
305

306
                $this->saveSignRequest($signRequest);
13✔
307

308
                $shouldNotify = $notify && $signRequest->getStatusEnum() === \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN;
13✔
309

310
                foreach ($identifyMethodsIncances as $identifyMethod) {
13✔
311
                        $identifyMethod->getEntity()->setSignRequestId($signRequest->getId());
13✔
312
                        $identifyMethod->willNotifyUser($shouldNotify);
13✔
313
                        $identifyMethod->save();
13✔
314
                }
315
                return $signRequest;
13✔
316
        }
317

318
        private function updateStatusIfAllowed(
319
                SignRequestEntity $signRequest,
320
                \OCA\Libresign\Enum\SignRequestStatus $currentStatus,
321
                \OCA\Libresign\Enum\SignRequestStatus $desiredStatus,
322
                bool $isNewSignRequest,
323
        ): void {
324
                if ($isNewSignRequest || $this->sequentialSigningService->isStatusUpgrade($currentStatus, $desiredStatus)) {
13✔
325
                        $signRequest->setStatusEnum($desiredStatus);
13✔
326
                }
327
        }
328

329
        private function determineInitialStatus(
330
                int $signingOrder,
331
                ?int $fileStatus = null,
332
                ?int $signerStatus = null,
333
                ?\OCA\Libresign\Enum\SignRequestStatus $currentStatus = null,
334
                ?int $fileId = null,
335
        ): \OCA\Libresign\Enum\SignRequestStatus {
336
                if ($signerStatus !== null) {
13✔
337
                        $desiredStatus = \OCA\Libresign\Enum\SignRequestStatus::from($signerStatus);
×
338
                        if ($currentStatus !== null && !$this->sequentialSigningService->isStatusUpgrade($currentStatus, $desiredStatus)) {
×
339
                                return $currentStatus;
×
340
                        }
341

342
                        // Validate status transition based on signing order
343
                        if ($fileId !== null) {
×
344
                                return $this->sequentialSigningService->validateStatusByOrder($desiredStatus, $signingOrder, $fileId);
×
345
                        }
346

347
                        return $desiredStatus;
×
348
                }
349

350
                // If fileStatus is explicitly DRAFT (0), keep signer as DRAFT
351
                // This allows adding new signers in DRAFT mode even when file is not in DRAFT status
352
                if ($fileStatus === FileEntity::STATUS_DRAFT) {
13✔
353
                        return \OCA\Libresign\Enum\SignRequestStatus::DRAFT;
×
354
                }
355

356
                if ($fileStatus === FileEntity::STATUS_ABLE_TO_SIGN) {
13✔
357
                        if ($this->sequentialSigningService->isOrderedNumericFlow()) {
13✔
358
                                // In ordered flow, only first signer (order 1) should be ABLE_TO_SIGN
359
                                // Others remain DRAFT until their turn
360
                                return $signingOrder === 1
×
361
                                        ? \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN
×
362
                                        : \OCA\Libresign\Enum\SignRequestStatus::DRAFT;
×
363
                        }
364
                        // In parallel flow, all can sign
365
                        return \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN;
13✔
366
                }
367

368
                if (!$this->sequentialSigningService->isOrderedNumericFlow()) {
×
369
                        return \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN;
×
370
                }
371

372
                return $signingOrder === 1
×
373
                        ? \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN
×
374
                        : \OCA\Libresign\Enum\SignRequestStatus::DRAFT;
×
375
        }
376

377
        /**
378
         * @param IIdentifyMethod[] $identifyMethodsIncances
379
         * @param string $displayName
380
         * @return string
381
         */
382
        private function getDisplayNameFromIdentifyMethodIfEmpty(array $identifyMethodsIncances, string $displayName): string {
383
                if (!empty($displayName)) {
13✔
384
                        return $displayName;
×
385
                }
386
                foreach ($identifyMethodsIncances as $identifyMethod) {
13✔
387
                        if ($identifyMethod->getName() === 'account') {
13✔
388
                                return $this->userManager->get($identifyMethod->getEntity()->getIdentifierValue())->getDisplayName();
2✔
389
                        }
390
                }
391
                foreach ($identifyMethodsIncances as $identifyMethod) {
11✔
392
                        if ($identifyMethod->getName() !== 'account') {
11✔
393
                                return $identifyMethod->getEntity()->getIdentifierValue();
11✔
394
                        }
395
                }
396
                return '';
×
397
        }
398

399
        private function saveVisibleElements(array $data, FileEntity $file): array {
400
                if (empty($data['visibleElements'])) {
17✔
401
                        return [];
15✔
402
                }
403
                $elements = $data['visibleElements'];
2✔
404
                foreach ($elements as $key => $element) {
2✔
405
                        $element['fileId'] = $file->getId();
2✔
406
                        $elements[$key] = $this->fileElementService->saveVisibleElement($element);
2✔
407
                }
408
                return $elements;
2✔
409
        }
410

411
        public function validateNewRequestToFile(array $data): void {
412
                $this->validateNewFile($data);
7✔
413
                $this->validateUsers($data);
6✔
414
                $this->validateHelper->validateFileStatus($data);
2✔
415
        }
416

417
        public function validateNewFile(array $data): void {
418
                if (empty($data['name'])) {
7✔
419
                        throw new \Exception($this->l10n->t('Name is mandatory'));
1✔
420
                }
421
                $this->validateHelper->validateNewFile($data);
6✔
422
        }
423

424
        public function validateUsers(array $data): void {
425
                if (empty($data['users'])) {
6✔
426
                        throw new \Exception($this->l10n->t('Empty users list'));
3✔
427
                }
428
                if (!is_array($data['users'])) {
3✔
429
                        // TRANSLATION This message will be displayed when the request to API with the key users has a value that is not an array
430
                        throw new \Exception($this->l10n->t('User list needs to be an array'));
1✔
431
                }
432
                foreach ($data['users'] as $user) {
2✔
433
                        if (!array_key_exists('identify', $user)) {
2✔
434
                                throw new \Exception('Identify key not found');
×
435
                        }
436
                        $this->identifyMethod->setAllEntityData($user);
2✔
437
                }
438
        }
439

440
        public function saveSignRequest(SignRequestEntity $signRequest): void {
441
                if ($signRequest->getId()) {
15✔
442
                        $this->signRequestMapper->update($signRequest);
1✔
443
                } else {
444
                        $this->signRequestMapper->insert($signRequest);
14✔
445
                }
446
        }
447

448
        /**
449
         * @psalm-suppress MixedMethodCall
450
         */
451
        private function setDataToUser(SignRequestEntity $signRequest, string $displayName, string $description, int $fileId): void {
452
                $signRequest->setFileId($fileId);
13✔
453
                if (!$signRequest->getUuid()) {
13✔
454
                        $signRequest->setUuid(UUIDUtil::getUUID());
13✔
455
                }
456
                if (!empty($displayName)) {
13✔
457
                        $signRequest->setDisplayName($displayName);
13✔
458
                }
459
                if (!empty($description)) {
13✔
460
                        $signRequest->setDescription($description);
×
461
                }
462
                if (!$signRequest->getId()) {
13✔
463
                        $signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
13✔
464
                }
465
        }
466

467
        private function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, int $fileId): SignRequestEntity {
468
                try {
469
                        $signRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId($identifyMethod, $fileId);
13✔
470
                } catch (DoesNotExistException) {
13✔
471
                        $signRequest = new SignRequestEntity();
13✔
472
                }
473
                return $signRequest;
13✔
474
        }
475

476
        public function unassociateToUser(int $fileId, int $signRequestId): void {
477
                $signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
2✔
478
                $deletedOrder = $signRequest->getSigningOrder();
2✔
479

480
                try {
481
                        $this->signRequestMapper->delete($signRequest);
2✔
482
                        $groupedIdentifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequestId);
2✔
483
                        foreach ($groupedIdentifyMethods as $identifyMethods) {
2✔
484
                                foreach ($identifyMethods as $identifyMethod) {
2✔
485
                                        $identifyMethod->delete();
2✔
486
                                }
487
                        }
488
                        $visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
2✔
489
                        foreach ($visibleElements as $visibleElement) {
2✔
490
                                $this->fileElementMapper->delete($visibleElement);
×
491
                        }
492

493
                        $this->sequentialSigningService->reorderAfterDeletion($fileId, $deletedOrder);
2✔
494
                } catch (\Throwable) {
×
495
                }
496
        }
497

498
        public function deleteRequestSignature(array $data): void {
499
                if (!empty($data['uuid'])) {
2✔
500
                        $signatures = $this->signRequestMapper->getByFileUuid($data['uuid']);
×
501
                        $fileData = $this->fileMapper->getByUuid($data['uuid']);
×
502
                } elseif (!empty($data['file']['fileId'])) {
2✔
503
                        $signatures = $this->signRequestMapper->getByNodeId($data['file']['fileId']);
2✔
504
                        $fileData = $this->fileMapper->getByFileId($data['file']['fileId']);
2✔
505
                } else {
506
                        throw new \Exception($this->l10n->t('Please provide either UUID or File object'));
×
507
                }
508
                foreach ($signatures as $signRequest) {
2✔
509
                        $this->signRequestMapper->delete($signRequest);
2✔
510
                }
511
                $this->fileMapper->delete($fileData);
2✔
512
                $this->fileElementService->deleteVisibleElements($fileData->getId());
2✔
513
        }
514
}
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