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

temporalio / sdk-java / #201

16 Oct 2023 03:47PM UTC coverage: 77.389% (+0.02%) from 77.368%
#201

push

github-actions

web-flow
Apply data converter context in more places (#1896)

Add data converter context to memo, lastFailure and schedules

23 of 23 new or added lines in 5 files covered. (100.0%)

18718 of 24187 relevant lines covered (77.39%)

0.77 hits per line

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

93.08
/temporal-sdk/src/main/java/io/temporal/client/WorkflowOptions.java
1
/*
2
 * Copyright (C) 2022 Temporal Technologies, Inc. All Rights Reserved.
3
 *
4
 * Copyright (C) 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
5
 *
6
 * Modifications copyright (C) 2017 Uber Technologies, Inc.
7
 *
8
 * Licensed under the Apache License, Version 2.0 (the "License");
9
 * you may not use this material except in compliance with the License.
10
 * You may obtain a copy of the License at
11
 *
12
 *   http://www.apache.org/licenses/LICENSE-2.0
13
 *
14
 * Unless required by applicable law or agreed to in writing, software
15
 * distributed under the License is distributed on an "AS IS" BASIS,
16
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
 * See the License for the specific language governing permissions and
18
 * limitations under the License.
19
 */
20

21
package io.temporal.client;
22

23
import com.google.common.base.Objects;
24
import io.temporal.api.enums.v1.WorkflowIdReusePolicy;
25
import io.temporal.common.CronSchedule;
26
import io.temporal.common.Experimental;
27
import io.temporal.common.MethodRetry;
28
import io.temporal.common.RetryOptions;
29
import io.temporal.common.SearchAttributes;
30
import io.temporal.common.context.ContextPropagator;
31
import io.temporal.internal.common.OptionsUtils;
32
import io.temporal.worker.WorkerFactory;
33
import io.temporal.workflow.Workflow;
34
import java.time.Duration;
35
import java.util.Collection;
36
import java.util.List;
37
import java.util.Map;
38
import javax.annotation.Nullable;
39

40
public final class WorkflowOptions {
41

42
  public static Builder newBuilder() {
43
    return new Builder();
1✔
44
  }
45

46
  public static Builder newBuilder(WorkflowOptions options) {
47
    return new Builder(options);
1✔
48
  }
49

50
  public static WorkflowOptions getDefaultInstance() {
51
    return DEFAULT_INSTANCE;
×
52
  }
53

54
  private static final WorkflowOptions DEFAULT_INSTANCE;
55

56
  static {
57
    DEFAULT_INSTANCE = WorkflowOptions.newBuilder().build();
1✔
58
  }
1✔
59

60
  public static WorkflowOptions merge(
61
      MethodRetry methodRetry, CronSchedule cronSchedule, WorkflowOptions o) {
62
    if (o == null) {
1✔
63
      o = WorkflowOptions.newBuilder().build();
×
64
    }
65
    String cronAnnotation = cronSchedule == null ? "" : cronSchedule.value();
1✔
66
    return WorkflowOptions.newBuilder()
1✔
67
        .setWorkflowId(o.getWorkflowId())
1✔
68
        .setWorkflowIdReusePolicy(o.getWorkflowIdReusePolicy())
1✔
69
        .setWorkflowRunTimeout(o.getWorkflowRunTimeout())
1✔
70
        .setWorkflowExecutionTimeout(o.getWorkflowExecutionTimeout())
1✔
71
        .setWorkflowTaskTimeout(o.getWorkflowTaskTimeout())
1✔
72
        .setTaskQueue(o.getTaskQueue())
1✔
73
        .setRetryOptions(RetryOptions.merge(methodRetry, o.getRetryOptions()))
1✔
74
        .setCronSchedule(OptionsUtils.merge(cronAnnotation, o.getCronSchedule(), String.class))
1✔
75
        .setMemo(o.getMemo())
1✔
76
        .setSearchAttributes(o.getSearchAttributes())
1✔
77
        .setTypedSearchAttributes(o.getTypedSearchAttributes())
1✔
78
        .setContextPropagators(o.getContextPropagators())
1✔
79
        .setDisableEagerExecution(o.isDisableEagerExecution())
1✔
80
        .setStartDelay(o.getStartDelay())
1✔
81
        .validateBuildWithDefaults();
1✔
82
  }
83

84
  public static final class Builder {
85

86
    private String workflowId;
87

88
    private WorkflowIdReusePolicy workflowIdReusePolicy;
89

90
    private Duration workflowRunTimeout;
91

92
    private Duration workflowExecutionTimeout;
93

94
    private Duration workflowTaskTimeout;
95

96
    private String taskQueue;
97

98
    private RetryOptions retryOptions;
99

100
    private String cronSchedule;
101

102
    private Map<String, Object> memo;
103

104
    private Map<String, ?> searchAttributes;
105

106
    private SearchAttributes typedSearchAttributes;
107

108
    private List<ContextPropagator> contextPropagators;
109

110
    private boolean disableEagerExecution = true;
1✔
111

112
    private Duration startDelay;
113

114
    private Builder() {}
1✔
115

116
    private Builder(WorkflowOptions options) {
1✔
117
      if (options == null) {
1✔
118
        return;
×
119
      }
120
      this.workflowIdReusePolicy = options.workflowIdReusePolicy;
1✔
121
      this.workflowId = options.workflowId;
1✔
122
      this.workflowTaskTimeout = options.workflowTaskTimeout;
1✔
123
      this.workflowRunTimeout = options.workflowRunTimeout;
1✔
124
      this.workflowExecutionTimeout = options.workflowExecutionTimeout;
1✔
125
      this.taskQueue = options.taskQueue;
1✔
126
      this.retryOptions = options.retryOptions;
1✔
127
      this.cronSchedule = options.cronSchedule;
1✔
128
      this.memo = options.memo;
1✔
129
      this.searchAttributes = options.searchAttributes;
1✔
130
      this.typedSearchAttributes = options.typedSearchAttributes;
1✔
131
      this.contextPropagators = options.contextPropagators;
1✔
132
      this.disableEagerExecution = options.disableEagerExecution;
1✔
133
      this.startDelay = options.startDelay;
1✔
134
    }
1✔
135

136
    /**
137
     * Workflow id to use when starting. If not specified a UUID is generated. Note that it is
138
     * dangerous as in case of client side retries no deduplication will happen based on the
139
     * generated id. So prefer assigning business meaningful ids if possible.
140
     */
141
    public Builder setWorkflowId(String workflowId) {
142
      this.workflowId = workflowId;
1✔
143
      return this;
1✔
144
    }
145

146
    /**
147
     * Specifies server behavior if a completed workflow with the same id exists. Note that under no
148
     * conditions Temporal allows two workflows with the same namespace and workflow id run
149
     * simultaneously.
150
     *
151
     * <p>Default value if not set: <b>AllowDuplicate</b>
152
     *
153
     * <ul>
154
     *   <li><b>AllowDuplicate</b> allows a new run regardless of the previous run's final status.
155
     *       The previous run still must be closed or the new run will be rejected.
156
     *   <li><b>AllowDuplicateFailedOnly</b> allows a new run if the previous run failed, was
157
     *       canceled, or terminated.
158
     *   <li><b>RejectDuplicate</b> never allows a new run, regardless of the previous run's final
159
     *       status.
160
     *   <li><b>TerminateIfRunning</b> is the same as <b>AllowDuplicate</b>, but if there exists a
161
     *       not-closed run in progress, it will be terminated.
162
     * </ul>
163
     */
164
    public Builder setWorkflowIdReusePolicy(WorkflowIdReusePolicy workflowIdReusePolicy) {
165
      this.workflowIdReusePolicy = workflowIdReusePolicy;
1✔
166
      return this;
1✔
167
    }
168

169
    /**
170
     * The time after which a workflow run is automatically terminated by Temporal service with
171
     * WORKFLOW_EXECUTION_TIMED_OUT status.
172
     *
173
     * <p>When a workflow reaches Workflow Run Timeout, it can't make any progress after that. Do
174
     * not rely on this timeout in workflow implementation or business logic. This timeout is not
175
     * designed to be handled in workflow code to perform any logic in case of timeout. Consider
176
     * using workflow timers instead.
177
     *
178
     * <p>If you catch yourself setting this timeout to very small values, you're likely using it
179
     * wrong.
180
     *
181
     * <p>Example: If Workflow Run Timeout is 30 seconds and the network was unavailable for 1
182
     * minute, workflows that were scheduled before the network blip will never have a chance to
183
     * make progress or react, and will be terminated. <br>
184
     * A timer that is scheduled in the workflow code using {@link Workflow#newTimer(Duration)} will
185
     * handle this situation gracefully. A workflow with such a timer will start after the network
186
     * blip. If it started before the network blip and the timer fires during the network blip, it
187
     * will get delivered after connectivity is restored and the workflow will be able to resume.
188
     */
189
    public Builder setWorkflowRunTimeout(Duration workflowRunTimeout) {
190
      this.workflowRunTimeout = workflowRunTimeout;
1✔
191
      return this;
1✔
192
    }
193

194
    /**
195
     * The time after which workflow execution (which includes run retries and continue as new) is
196
     * automatically terminated by Temporal service with WORKFLOW_EXECUTION_TIMED_OUT status.
197
     *
198
     * <p>When a workflow reaches Workflow Execution Timeout, it can't make any progress after that.
199
     * Do not rely on this timeout in workflow implementation or business logic. This timeout is not
200
     * designed to be handled in workflow code to perform any logic in case of timeout. Consider
201
     * using workflow timers instead.
202
     *
203
     * <p>If you catch yourself setting this timeout to very small values, you're likely using it
204
     * wrong.
205
     *
206
     * <p>Example: If Workflow Execution Timeout is 30 seconds and the network was unavailable for 1
207
     * minute, workflows that were scheduled before the network blip will never have a chance to
208
     * make progress or react, and will be terminated. <br>
209
     * A timer that is scheduled in the workflow code using {@link Workflow#newTimer(Duration)} will
210
     * handle this situation gracefully. A workflow with such a timer will start after the network
211
     * blip. If it started before the network blip and the timer fires during the network blip, it
212
     * will get delivered after connectivity is restored and the workflow will be able to resume.
213
     */
214
    public Builder setWorkflowExecutionTimeout(Duration workflowExecutionTimeout) {
215
      this.workflowExecutionTimeout = workflowExecutionTimeout;
1✔
216
      return this;
1✔
217
    }
218

219
    /**
220
     * Maximum execution time of a single Workflow Task. In the majority of cases there is no need
221
     * to change this timeout. Note that this timeout is not related to the overall Workflow
222
     * duration in any way. It defines for how long the Workflow can get blocked in the case of a
223
     * Workflow Worker crash.
224
     *
225
     * <p>Default is 10 seconds. Maximum value allowed by the Temporal Server is 1 minute.
226
     */
227
    public Builder setWorkflowTaskTimeout(Duration workflowTaskTimeout) {
228
      this.workflowTaskTimeout = workflowTaskTimeout;
1✔
229
      return this;
1✔
230
    }
231

232
    /**
233
     * Task queue to use for workflow tasks. It should match a task queue specified when creating a
234
     * {@link io.temporal.worker.Worker} that hosts the workflow code.
235
     */
236
    public Builder setTaskQueue(String taskQueue) {
237
      this.taskQueue = taskQueue;
1✔
238
      return this;
1✔
239
    }
240

241
    public Builder setRetryOptions(RetryOptions retryOptions) {
242
      this.retryOptions = retryOptions;
1✔
243
      return this;
1✔
244
    }
245

246
    public Builder setCronSchedule(String cronSchedule) {
247
      this.cronSchedule = cronSchedule;
1✔
248
      return this;
1✔
249
    }
250

251
    /**
252
     * Specifies additional non-indexed information in result of list workflow. The type of value
253
     * can be any object that are serializable by {@link io.temporal.common.converter.DataConverter}
254
     */
255
    public Builder setMemo(Map<String, Object> memo) {
256
      this.memo = memo;
1✔
257
      return this;
1✔
258
    }
259

260
    /**
261
     * Specifies Search Attributes map {@code searchAttributes} that will be attached to the
262
     * Workflow. Search Attributes are additional indexed information attributed to workflow and
263
     * used for search and visibility.
264
     *
265
     * <p>The search attributes can be used in query of List/Scan/Count workflow APIs. The key and
266
     * its value type must be registered on Temporal server side.
267
     *
268
     * <p>Supported Java types of the value:
269
     *
270
     * <ul>
271
     *   <li>String
272
     *   <li>Long, Integer, Short, Byte
273
     *   <li>Boolean
274
     *   <li>Double
275
     *   <li>OffsetDateTime
276
     *   <li>{@link Collection} of the types above
277
     * </ul>
278
     *
279
     * @deprecated use {@link #setTypedSearchAttributes} instead.
280
     */
281
    // Workflow#upsertSearchAttributes docs needs to be kept in sync with this method
282
    @Deprecated
283
    public Builder setSearchAttributes(Map<String, ?> searchAttributes) {
284
      if (searchAttributes != null
1✔
285
          && !searchAttributes.isEmpty()
1✔
286
          && this.typedSearchAttributes != null) {
287
        throw new IllegalArgumentException(
×
288
            "Cannot have search attributes and typed search attributes");
289
      }
290
      this.searchAttributes = searchAttributes;
1✔
291
      return this;
1✔
292
    }
293

294
    /**
295
     * Specifies Search Attributes that will be attached to the Workflow. Search Attributes are
296
     * additional indexed information attributed to workflow and used for search and visibility.
297
     *
298
     * <p>The search attributes can be used in query of List/Scan/Count workflow APIs. The key and
299
     * its value type must be registered on Temporal server side.
300
     */
301
    public Builder setTypedSearchAttributes(SearchAttributes typedSearchAttributes) {
302
      if (typedSearchAttributes != null
1✔
303
          && searchAttributes != null
304
          && !searchAttributes.isEmpty()) {
×
305
        throw new IllegalArgumentException(
×
306
            "Cannot have typed search attributes and search attributes");
307
      }
308
      this.typedSearchAttributes = typedSearchAttributes;
1✔
309
      return this;
1✔
310
    }
311

312
    /**
313
     * This list of context propagators overrides the list specified on {@link
314
     * WorkflowClientOptions#getContextPropagators()}. <br>
315
     * This method is uncommon, the majority of users should just set {@link
316
     * WorkflowClientOptions#getContextPropagators()}
317
     *
318
     * @param contextPropagators specifies the list of overriding context propagators, {@code null}
319
     *     means no overriding.
320
     */
321
    public Builder setContextPropagators(@Nullable List<ContextPropagator> contextPropagators) {
322
      this.contextPropagators = contextPropagators;
1✔
323
      return this;
1✔
324
    }
325

326
    /**
327
     * If {@link WorkflowClient} is used to create a {@link WorkerFactory} that is
328
     *
329
     * <ul>
330
     *   <li>started
331
     *   <li>has a non-paused worker on the right task queue
332
     *   <li>has available workflow task executor slots
333
     * </ul>
334
     *
335
     * and such a {@link WorkflowClient} is used to start a workflow, then the first workflow task
336
     * could be dispatched on this local worker with the response to the start call if Server
337
     * supports it. This option can be used to disable this mechanism.
338
     *
339
     * <p>Default is true
340
     *
341
     * <p>WARNING: Eager start does not respect worker versioning. An eagerly started workflow may
342
     * run on any available local worker even if that worker is not in the default build ID set.
343
     *
344
     * @param disableEagerExecution if true, an eager local execution of the workflow task will
345
     *     never be requested even if it is possible.
346
     */
347
    public Builder setDisableEagerExecution(boolean disableEagerExecution) {
348
      this.disableEagerExecution = disableEagerExecution;
1✔
349
      return this;
1✔
350
    }
351

352
    /**
353
     * Time to wait before dispatching the first workflow task. If the workflow gets a signal before
354
     * the delay, a workflow task will be dispatched and the rest of the delay will be ignored. A
355
     * signal from signal with start will not trigger a workflow task. Cannot be set the same time
356
     * as a CronSchedule.
357
     */
358
    @Experimental
359
    public Builder setStartDelay(Duration startDelay) {
360
      this.startDelay = startDelay;
1✔
361
      return this;
1✔
362
    }
363

364
    public WorkflowOptions build() {
365
      return new WorkflowOptions(
1✔
366
          workflowId,
367
          workflowIdReusePolicy,
368
          workflowRunTimeout,
369
          workflowExecutionTimeout,
370
          workflowTaskTimeout,
371
          taskQueue,
372
          retryOptions,
373
          cronSchedule,
374
          memo,
375
          searchAttributes,
376
          typedSearchAttributes,
377
          contextPropagators,
378
          disableEagerExecution,
379
          startDelay);
380
    }
381

382
    /**
383
     * Validates that all required properties are set and fills all other with default parameters.
384
     */
385
    public WorkflowOptions validateBuildWithDefaults() {
386
      return new WorkflowOptions(
1✔
387
          workflowId,
388
          workflowIdReusePolicy,
389
          workflowRunTimeout,
390
          workflowExecutionTimeout,
391
          workflowTaskTimeout,
392
          taskQueue,
393
          retryOptions,
394
          cronSchedule,
395
          memo,
396
          searchAttributes,
397
          typedSearchAttributes,
398
          contextPropagators,
399
          disableEagerExecution,
400
          startDelay);
401
    }
402
  }
403

404
  private final String workflowId;
405

406
  private final WorkflowIdReusePolicy workflowIdReusePolicy;
407

408
  private final Duration workflowRunTimeout;
409

410
  private final Duration workflowExecutionTimeout;
411

412
  private final Duration workflowTaskTimeout;
413

414
  private final String taskQueue;
415

416
  private final RetryOptions retryOptions;
417

418
  private final String cronSchedule;
419

420
  private final Map<String, Object> memo;
421

422
  private final Map<String, ?> searchAttributes;
423

424
  private final SearchAttributes typedSearchAttributes;
425

426
  private final List<ContextPropagator> contextPropagators;
427

428
  private final boolean disableEagerExecution;
429

430
  private final Duration startDelay;
431

432
  private WorkflowOptions(
433
      String workflowId,
434
      WorkflowIdReusePolicy workflowIdReusePolicy,
435
      Duration workflowRunTimeout,
436
      Duration workflowExecutionTimeout,
437
      Duration workflowTaskTimeout,
438
      String taskQueue,
439
      RetryOptions retryOptions,
440
      String cronSchedule,
441
      Map<String, Object> memo,
442
      Map<String, ?> searchAttributes,
443
      SearchAttributes typedSearchAttributes,
444
      List<ContextPropagator> contextPropagators,
445
      boolean disableEagerExecution,
446
      Duration startDelay) {
1✔
447
    this.workflowId = workflowId;
1✔
448
    this.workflowIdReusePolicy = workflowIdReusePolicy;
1✔
449
    this.workflowRunTimeout = workflowRunTimeout;
1✔
450
    this.workflowExecutionTimeout = workflowExecutionTimeout;
1✔
451
    this.workflowTaskTimeout = workflowTaskTimeout;
1✔
452
    this.taskQueue = taskQueue;
1✔
453
    this.retryOptions = retryOptions;
1✔
454
    this.cronSchedule = cronSchedule;
1✔
455
    this.memo = memo;
1✔
456
    this.searchAttributes = searchAttributes;
1✔
457
    this.typedSearchAttributes = typedSearchAttributes;
1✔
458
    this.contextPropagators = contextPropagators;
1✔
459
    this.disableEagerExecution = disableEagerExecution;
1✔
460
    this.startDelay = startDelay;
1✔
461
  }
1✔
462

463
  public String getWorkflowId() {
464
    return workflowId;
1✔
465
  }
466

467
  public WorkflowIdReusePolicy getWorkflowIdReusePolicy() {
468
    return workflowIdReusePolicy;
1✔
469
  }
470

471
  public Duration getWorkflowRunTimeout() {
472
    return workflowRunTimeout;
1✔
473
  }
474

475
  public Duration getWorkflowExecutionTimeout() {
476
    return workflowExecutionTimeout;
1✔
477
  }
478

479
  public Duration getWorkflowTaskTimeout() {
480
    return workflowTaskTimeout;
1✔
481
  }
482

483
  public String getTaskQueue() {
484
    return taskQueue;
1✔
485
  }
486

487
  public RetryOptions getRetryOptions() {
488
    return retryOptions;
1✔
489
  }
490

491
  public String getCronSchedule() {
492
    return cronSchedule;
1✔
493
  }
494

495
  public Map<String, Object> getMemo() {
496
    return memo;
1✔
497
  }
498

499
  /**
500
   * @deprecated use {@link #getTypedSearchAttributes} instead.
501
   */
502
  @Deprecated
503
  public Map<String, ?> getSearchAttributes() {
504
    return searchAttributes;
1✔
505
  }
506

507
  public SearchAttributes getTypedSearchAttributes() {
508
    return typedSearchAttributes;
1✔
509
  }
510

511
  /**
512
   * @return the list of context propagators to use during this workflow. This list overrides the
513
   *     list specified on {@link WorkflowClientOptions#getContextPropagators()}, {@code null} means
514
   *     no overriding
515
   */
516
  public @Nullable List<ContextPropagator> getContextPropagators() {
517
    return contextPropagators;
1✔
518
  }
519

520
  public boolean isDisableEagerExecution() {
521
    return disableEagerExecution;
1✔
522
  }
523

524
  public @Nullable Duration getStartDelay() {
525
    return startDelay;
1✔
526
  }
527

528
  public Builder toBuilder() {
529
    return new Builder(this);
1✔
530
  }
531

532
  @Override
533
  public boolean equals(Object o) {
534
    if (this == o) return true;
1✔
535
    if (o == null || getClass() != o.getClass()) return false;
1✔
536
    WorkflowOptions that = (WorkflowOptions) o;
1✔
537
    return Objects.equal(workflowId, that.workflowId)
1✔
538
        && workflowIdReusePolicy == that.workflowIdReusePolicy
539
        && Objects.equal(workflowRunTimeout, that.workflowRunTimeout)
1✔
540
        && Objects.equal(workflowExecutionTimeout, that.workflowExecutionTimeout)
1✔
541
        && Objects.equal(workflowTaskTimeout, that.workflowTaskTimeout)
1✔
542
        && Objects.equal(taskQueue, that.taskQueue)
1✔
543
        && Objects.equal(retryOptions, that.retryOptions)
1✔
544
        && Objects.equal(cronSchedule, that.cronSchedule)
1✔
545
        && Objects.equal(memo, that.memo)
1✔
546
        && Objects.equal(searchAttributes, that.searchAttributes)
1✔
547
        && Objects.equal(typedSearchAttributes, that.typedSearchAttributes)
1✔
548
        && Objects.equal(contextPropagators, that.contextPropagators)
1✔
549
        && Objects.equal(disableEagerExecution, that.disableEagerExecution)
1✔
550
        && Objects.equal(startDelay, that.startDelay);
1✔
551
  }
552

553
  @Override
554
  public int hashCode() {
555
    return Objects.hashCode(
×
556
        workflowId,
557
        workflowIdReusePolicy,
558
        workflowRunTimeout,
559
        workflowExecutionTimeout,
560
        workflowTaskTimeout,
561
        taskQueue,
562
        retryOptions,
563
        cronSchedule,
564
        memo,
565
        searchAttributes,
566
        typedSearchAttributes,
567
        contextPropagators,
568
        disableEagerExecution,
×
569
        startDelay);
570
  }
571

572
  @Override
573
  public String toString() {
574
    return "WorkflowOptions{"
×
575
        + "workflowId='"
576
        + workflowId
577
        + '\''
578
        + ", workflowIdReusePolicy="
579
        + workflowIdReusePolicy
580
        + ", workflowRunTimeout="
581
        + workflowRunTimeout
582
        + ", workflowExecutionTimeout="
583
        + workflowExecutionTimeout
584
        + ", workflowTaskTimeout="
585
        + workflowTaskTimeout
586
        + ", taskQueue='"
587
        + taskQueue
588
        + '\''
589
        + ", retryOptions="
590
        + retryOptions
591
        + ", cronSchedule='"
592
        + cronSchedule
593
        + '\''
594
        + ", memo="
595
        + memo
596
        + ", searchAttributes="
597
        + searchAttributes
598
        + ", typedSearchAttributes="
599
        + typedSearchAttributes
600
        + ", contextPropagators="
601
        + contextPropagators
602
        + ", disableEagerExecution="
603
        + disableEagerExecution
604
        + ", startDelay="
605
        + startDelay
606
        + '}';
607
  }
608
}
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