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

SwissDataScienceCenter / renku-python / 5948296099

23 Aug 2023 07:23AM UTC coverage: 85.801% (+0.04%) from 85.766%
5948296099

Pull #3601

github-actions

olevski
chore: run poetry lock
Pull Request #3601: hotfix: v2.6.1

40 of 48 new or added lines in 10 files covered. (83.33%)

285 existing lines in 25 files now uncovered.

25875 of 30157 relevant lines covered (85.8%)

4.9 hits per line

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

93.93
/renku/core/workflow/activity.py
1
#
2
# Copyright 2017-2023 - Swiss Data Science Center (SDSC)
3
# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
4
# Eidgenössische Technische Hochschule Zürich (ETHZ).
5
#
6
# Licensed under the Apache License, Version 2.0 (the "License");
7
# you may not use this file except in compliance with the License.
8
# You may obtain a copy of the License at
9
#
10
#     http://www.apache.org/licenses/LICENSE-2.0
11
#
12
# Unless required by applicable law or agreed to in writing, software
13
# distributed under the License is distributed on an "AS IS" BASIS,
14
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
# See the License for the specific language governing permissions and
16
# limitations under the License.
17
"""Activity management."""
11✔
18

19
import itertools
11✔
20
import os
11✔
21
from collections import defaultdict
11✔
22
from pathlib import Path
11✔
23
from typing import Dict, FrozenSet, Iterable, List, NamedTuple, Optional, Set, Tuple
11✔
24

25
import networkx
11✔
26
from pydantic import validate_arguments
11✔
27

28
from renku.command.command_builder import inject
11✔
29
from renku.core import errors
11✔
30
from renku.core.interface.activity_gateway import IActivityGateway
11✔
31
from renku.core.util import communication
11✔
32
from renku.core.workflow.plan import get_activities, is_plan_removed, remove_plan
11✔
33
from renku.domain_model.entity import Entity
11✔
34
from renku.domain_model.project_context import project_context
11✔
35
from renku.domain_model.provenance.activity import Activity, Usage
11✔
36

37

38
def get_activities_until_paths(
11✔
39
    paths: List[str], sources: List[str], activity_gateway: IActivityGateway, revision: Optional[str] = None
40
) -> Set[Activity]:
41
    """Get all current activities leading to `paths`, from `sources`."""
42
    all_activities: Dict[str, Set[Activity]] = defaultdict(set)
2✔
43

44
    def include_newest_activity(activity):
2✔
45
        existing_activities = all_activities[activity.association.plan.id]
2✔
46
        add_activity_if_recent(activity=activity, activities=existing_activities)
2✔
47

48
    repository = project_context.repository
2✔
49
    commit = None
2✔
50

51
    if revision:
2✔
52
        commit = repository.get_commit(revision)
2✔
53

54
    for path in paths:
2✔
55
        checksum = None
2✔
56
        if commit:
2✔
57
            try:
2✔
58
                blob = commit.tree[path]
2✔
59
            except KeyError:
×
60
                raise errors.GitError(f"Couldn't find file {path} at revision {revision}")
×
61
            checksum = blob.hexsha
2✔
62

63
        activities = activity_gateway.get_activities_by_generation(path, checksum=checksum)
2✔
64

65
        if len(activities) == 0:
2✔
66
            communication.warn(f"Path '{path}' is not generated by any workflows.")
2✔
67
            continue
2✔
68

69
        latest_activity = max(activities, key=lambda a: a.ended_at_time)
2✔
70

71
        upstream_chains = activity_gateway.get_upstream_activity_chains(latest_activity)
2✔
72

73
        if sources:
2✔
74
            # NOTE: Add the activity to check if it also matches the condition
75
            upstream_chains.append((latest_activity,))
2✔
76
            # NOTE: Only include paths that is using at least one of the sources
77
            upstream_chains = [c for c in upstream_chains if any(u.entity.path in sources for u in c[-1].usages)]
2✔
78

79
            # NOTE: Include activity only if any of its upstream match the condition
80
            if upstream_chains:
2✔
81
                include_newest_activity(latest_activity)
2✔
82
        else:
83
            include_newest_activity(latest_activity)
2✔
84

85
        for chain in upstream_chains:
2✔
86
            for activity in chain:
2✔
87
                include_newest_activity(activity)
2✔
88

89
    return {a for activities in all_activities.values() for a in activities}
2✔
90

91

92
def create_activity_graph(
11✔
93
    activities: List[Activity],
94
    remove_overridden_parents=True,
95
    with_inputs_outputs=False,
96
    with_hidden_dependencies: bool = False,
97
) -> networkx.Graph:
98
    """Create a dependency DAG from activities."""
99
    by_usage: Dict[str, Set[Activity]] = defaultdict(set)
6✔
100
    by_generation: Dict[str, Set[Activity]] = defaultdict(set)
6✔
101

102
    overridden_activities: Dict[Activity, Set[str]] = defaultdict(set)
6✔
103

104
    graph = networkx.DiGraph()
6✔
105

106
    def connect_nodes_based_on_dependencies():
6✔
107
        for activity in activities:
6✔
108
            # NOTE: Make sure that activity is in the graph in case it has no connection to others
109
            graph.add_node(activity)
6✔
110
            if with_inputs_outputs:
6✔
111
                create_input_output_edges(activity)
6✔
112

113
            collection = (
6✔
114
                itertools.chain(activity.usages, activity.hidden_usages)
115
                if with_hidden_dependencies
116
                else activity.usages
117
            )
118
            for usage in collection:
6✔
119
                path = usage.entity.path
6✔
120
                by_usage[path].add(activity)
6✔
121
                parent_activities = by_generation[path]
6✔
122
                for parent in parent_activities:
6✔
123
                    create_edge(parent, activity, path)
4✔
124

125
            for generation in activity.generations:
6✔
126
                path = generation.entity.path
6✔
127
                by_generation[path].add(activity)
6✔
128
                child_activities = by_usage[path]
6✔
129
                for child in child_activities:
6✔
130
                    create_edge(activity, child, path)
4✔
131

132
    def create_input_output_edges(activity):
6✔
133
        for generation in activity.generations:
6✔
134
            path = generation.entity.path
6✔
135
            if not graph.has_node(path):
6✔
136
                graph.add_node(path)
6✔
137
            if not graph.has_edge(activity, path):
6✔
138
                graph.add_edge(activity, path)
6✔
139
        collection = (
6✔
140
            itertools.chain(activity.usages, activity.hidden_usages) if with_hidden_dependencies else activity.usages
141
        )
142
        for usage in collection:
6✔
143
            path = usage.entity.path
6✔
144
            if not graph.has_node(path):
6✔
145
                graph.add_node(path)
6✔
146
            if not graph.has_edge(path, activity):
6✔
147
                graph.add_edge(path, activity)
6✔
148

149
    def create_edge(parent, child, path: str):
6✔
150
        if with_inputs_outputs:
4✔
151
            if not graph.has_edge(parent, path):
4✔
152
                graph.add_edge(parent, path)
×
153
            if not graph.has_edge(path, child):
4✔
154
                graph.add_edge(path, child)
×
155
        else:
156
            if graph.has_edge(parent, child):
2✔
157
                return
×
158
            graph.add_edge(parent, child, path=path)
2✔
159

160
    def connect_nodes_by_execution_order():
6✔
161
        for path, values in by_generation.items():
6✔
162
            if len(values) <= 1:
6✔
163
                continue
6✔
164

165
            # NOTE: Order multiple activities that generate a common path
166
            create_order_among_activities(values, path)
4✔
167

168
    def create_order_among_activities(activities: Set[Activity], path):
6✔
169
        for a, b in itertools.combinations(activities, 2):
4✔
170
            if (networkx.has_path(graph, a, b) and path in overridden_activities[a]) or (
4✔
171
                networkx.has_path(graph, b, a) and path in overridden_activities[b]
172
            ):
173
                continue
2✔
174

175
            # NOTE: More recent activity should be executed after the other one
176
            # NOTE: This won't introduce a cycle in the graph because there is no other path between the two nodes
177
            comparison = a.compare_to(b)
4✔
178
            if comparison < 0:
4✔
179
                if not networkx.has_path(graph, a, b):
4✔
180
                    graph.add_edge(a, b)
2✔
181
                overridden_activities[a].add(path)
4✔
182
            elif comparison > 0:
4✔
183
                if not networkx.has_path(graph, b, a):
4✔
184
                    graph.add_edge(b, a)
2✔
185
                overridden_activities[b].add(path)
4✔
186
            else:
187
                raise ValueError(f"Cannot create an order between activities that generate '{path}': {a} and {b}")
×
188

189
    def remove_overridden_activities():
6✔
190
        to_be_removed = set()
6✔
191
        to_be_processed = set(overridden_activities.keys())
6✔
192

193
        while len(to_be_processed) > 0:
6✔
194
            activity = to_be_processed.pop()
4✔
195
            overridden_paths = overridden_activities[activity]
4✔
196
            generated_path = {g.entity.path for g in activity.generations}
4✔
197
            if generated_path != overridden_paths:
4✔
198
                continue
4✔
199

200
            # NOTE: All generated paths are overridden; there is no point in executing the activity
201
            to_be_removed.add(activity)
2✔
202

203
            if not remove_overridden_parents:
2✔
204
                continue
2✔
205

206
            # NOTE: Check if its parents can be removed as well
207
            for parent in graph.predecessors(activity):
2✔
208
                if parent in to_be_removed:
2✔
209
                    continue
2✔
210
                data = graph.get_edge_data(parent, activity)
2✔
211
                if data and "path" in data:
2✔
212
                    overridden_activities[parent].add(data["path"])
×
213
                    to_be_processed.add(parent)
×
214

215
        for activity in to_be_removed:
6✔
216
            graph.remove_node(activity)
2✔
217

218
    connect_nodes_based_on_dependencies()
6✔
219

220
    cycles = list(networkx.algorithms.cycles.simple_cycles(graph))
6✔
221

222
    if cycles:
6✔
223
        cycles = [map(lambda x: getattr(x, "id", x), cycle) for cycle in cycles]
×
224
        raise errors.GraphCycleError(cycles)
×
225

226
    connect_nodes_by_execution_order()
6✔
227
    remove_overridden_activities()
6✔
228

229
    return graph
6✔
230

231

232
def sort_activities(activities: List[Activity], remove_overridden_parents=True) -> List[Activity]:
11✔
233
    """Return a sorted list of activities based on their dependencies and execution order."""
234
    graph = create_activity_graph(activities, remove_overridden_parents)
2✔
235

236
    return list(networkx.topological_sort(graph))
2✔
237

238

239
class ModifiedActivitiesEntities(NamedTuple):
11✔
240
    """A class containing sets of modified/deleted activities and entities for both normal and hidden entities."""
241

242
    modified: Set[Tuple[Activity, Entity]]
11✔
243
    """Set of modified activity and entity tuples."""
11✔
244

245
    deleted: Set[Tuple[Activity, Entity]]
11✔
246
    """Set of deleted activity and entity tuples."""
11✔
247

248
    hidden_modified: Set[Tuple[Activity, Entity]]
11✔
249
    """Set of modified activity and entity tuples for hidden entities."""
11✔
250

251

252
@inject.autoparams("activity_gateway")
11✔
253
def get_all_modified_and_deleted_activities_and_entities(
11✔
254
    repository, activity_gateway: IActivityGateway, check_hidden_dependencies: bool = False
255
) -> ModifiedActivitiesEntities:
256
    """
257
    Return latest activities with at least one modified or deleted input along with the modified/deleted input entity.
258

259
    An activity can be repeated if more than one of its inputs are modified.
260

261
    Args:
262
        repository: The current ``Repository``.
263
        activity_gateway(IActivityGateway): The injected Activity gateway.
264

265
    Returns:
266
        ModifiedActivitiesEntities: Modified and deleted activities and entities.
267

268
    """
269
    all_activities = activity_gateway.get_all_activities()
6✔
270
    relevant_activities = filter_overridden_activities(all_activities)
6✔
271
    return get_modified_activities(
6✔
272
        activities=relevant_activities, repository=repository, check_hidden_dependencies=check_hidden_dependencies
273
    )
274

275

276
@inject.autoparams()
11✔
277
def get_downstream_generating_activities(
11✔
278
    starting_activities: Set[Activity],
279
    paths: List[str],
280
    ignore_deleted: bool,
281
    project_path: Path,
282
    activity_gateway: IActivityGateway,
283
) -> List[Activity]:
284
    """Return activities downstream of passed activities that generate at least a path in ``paths``.
285

286
    Args:
287
        starting_activities(Set[Activity]): Activities to use as starting/upstream nodes.
288
        paths(List[str]): Optional generated paths to end downstream chains at.
289
        ignore_deleted(bool): Whether to ignore deleted generations.
290
        project_path(Path): Path to project's root directory.
291
        activity_gateway(IActivityGateway): The injected Activity gateway.
292

293
    Returns:
294
        Set[Activity]: All activities and their downstream activities.
295

296
    """
297
    all_activities: Dict[str, Set[Activity]] = defaultdict(set)
6✔
298

299
    def include_newest_activity(activity):
6✔
300
        existing_activities = all_activities[activity.association.plan.id]
6✔
301
        add_activity_if_recent(activity=activity, activities=existing_activities)
6✔
302

303
    def does_activity_generate_any_paths(activity) -> bool:
6✔
304
        is_same = any(g.entity.path in paths for g in activity.generations)
4✔
305
        is_parent = any(Path(p) in Path(g.entity.path).parents for p in paths for g in activity.generations)
4✔
306

307
        return is_same or is_parent
4✔
308

309
    def has_an_existing_generation(activity) -> bool:
6✔
310
        for generation in activity.generations:
4✔
311
            if (project_path / generation.entity.path).exists():
4✔
312
                return True
4✔
313

314
        return False
4✔
315

316
    for starting_activity in starting_activities:
6✔
317
        downstream_chains = activity_gateway.get_downstream_activity_chains(starting_activity)
6✔
318

319
        if paths:
6✔
320
            # NOTE: Add the activity to check if it also matches the condition
321
            downstream_chains.append((starting_activity,))
4✔
322
            downstream_chains = [c for c in downstream_chains if does_activity_generate_any_paths(c[-1])]
4✔
323
            # NOTE: Include activity only if any of its downstream matched the condition
324
            include_starting_activity = len(downstream_chains) > 0
4✔
325
        elif ignore_deleted:  # NOTE: Excluded deleted generations only if they are not passed in ``paths``
6✔
326
            # NOTE: Add the activity to check if it also matches the condition
327
            downstream_chains.append((starting_activity,))
4✔
328
            downstream_chains = [c for c in downstream_chains if has_an_existing_generation(c[-1])]
4✔
329
            # NOTE: Include activity only if any of its downstream matched the condition
330
            include_starting_activity = len(downstream_chains) > 0
4✔
331
        else:
332
            include_starting_activity = True
6✔
333

334
        if include_starting_activity:
6✔
335
            include_newest_activity(starting_activity)
6✔
336

337
        for chain in downstream_chains:
6✔
338
            for activity in chain:
4✔
339
                if not is_activity_valid(activity):
4✔
340
                    # don't process further downstream activities as the plan in question was deleted
341
                    break
×
342
                include_newest_activity(activity)
4✔
343

344
    return list({a for activities in all_activities.values() for a in activities})
6✔
345

346

347
def get_modified_activities(
11✔
348
    activities: FrozenSet[Activity], repository, check_hidden_dependencies: bool
349
) -> ModifiedActivitiesEntities:
350
    """Get lists of activities that have modified/deleted usage entities."""
351

352
    def get_modified_activities_helper(hashes, modified, deleted, hidden: bool):
6✔
353
        for activity in activities:
6✔
354
            collection: List[Usage] = activity.hidden_usages if hidden else activity.usages  # type: ignore
6✔
355
            for usage in collection:
6✔
356
                entity = usage.entity
6✔
357
                current_checksum = hashes.get(entity.path, None)
6✔
358
                usage_path = repository.path / usage.entity.path
6✔
359
                if current_checksum is None or not usage_path.exists():
6✔
360
                    deleted.add((activity, entity))
4✔
361
                elif current_checksum != entity.checksum:
6✔
362
                    modified.add((activity, entity))
6✔
363

364
    modified: Set[Tuple[Activity, Entity]] = set()
6✔
365
    deleted: Set[Tuple[Activity, Entity]] = set()
6✔
366
    hidden_modified: Set[Tuple[Activity, Entity]] = set()
6✔
367

368
    paths = []
6✔
369
    hidden_paths = []
6✔
370

371
    for activity in activities:
6✔
372
        for usage in activity.usages:
6✔
373
            paths.append(usage.entity.path)
6✔
374
        if check_hidden_dependencies:
6✔
375
            for usage in activity.hidden_usages:
6✔
376
                hidden_paths.append(usage.entity.path)
2✔
377

378
    hashes = repository.get_object_hashes(paths=paths)
6✔
379
    get_modified_activities_helper(hashes=hashes, modified=modified, deleted=deleted, hidden=False)
6✔
380

381
    if check_hidden_dependencies and hidden_paths:
6✔
382
        hashes = repository.get_object_hashes(paths=hidden_paths)
2✔
383
        get_modified_activities_helper(hashes=hashes, modified=hidden_modified, deleted=set(), hidden=True)
2✔
384

385
    return ModifiedActivitiesEntities(modified=modified, deleted=deleted, hidden_modified=hidden_modified)
6✔
386

387

388
def filter_overridden_activities(activities: List[Activity]) -> FrozenSet[Activity]:
11✔
389
    """Filter out overridden activities from a list of activities."""
390
    relevant_activities: Dict[FrozenSet[str], Activity] = {}
6✔
391

392
    for activity in activities[::-1]:
6✔
393
        outputs = frozenset(g.entity.path for g in activity.generations)
6✔
394

395
        subset_of = set()
6✔
396
        superset_of = set()
6✔
397

398
        for o, a in relevant_activities.items():
6✔
399
            if outputs.issubset(o):
6✔
400
                subset_of.add((o, a))
2✔
401
            elif outputs.issuperset(o):
6✔
402
                superset_of.add((o, a))
4✔
403

404
        if not subset_of and not superset_of:
6✔
405
            relevant_activities[outputs] = activity
6✔
406
            continue
6✔
407

408
        if subset_of and any(activity.ended_at_time < a.ended_at_time for _, a in subset_of):
4✔
409
            # activity is a subset of another, newer activity, ignore it
410
            continue
2✔
411

412
        older_subsets = [o for o, a in superset_of if activity.ended_at_time > a.ended_at_time]
4✔
413

414
        for older_subset in older_subsets:
4✔
415
            # remove other activities that this activity is a superset of
UNCOV
416
            del relevant_activities[older_subset]
×
417

418
        relevant_activities[outputs] = activity
4✔
419

420
    return frozenset(relevant_activities.values())
6✔
421

422

423
def add_activity_if_recent(activity: Activity, activities: Set[Activity]):
11✔
424
    """Add ``activity`` to ``activities`` if it's not in the set or is the latest executed instance.
425

426
    Remove existing activities that were executed earlier.
427
    """
428
    if activity in activities:
6✔
429
        return
4✔
430

431
    for existing_activity in activities:
6✔
432
        if activity.has_identical_inputs_and_outputs_as(existing_activity):
2✔
433
            if activity.ended_at_time > existing_activity.ended_at_time:  # activity is newer
2✔
434
                activities.remove(existing_activity)
2✔
435
                activities.add(activity)
2✔
436
            return
2✔
437

438
    # NOTE: No similar activity was found
439
    activities.add(activity)
6✔
440

441

442
def get_latest_activity(activities: Iterable[Activity]) -> Optional[Activity]:
11✔
443
    """Return the activity that was executed after all other activities."""
444
    return max(activities, key=lambda a: a.ended_at_time) if activities else None
4✔
445

446

447
def get_latest_activity_before(activities: Iterable[Activity], activity: Activity) -> Optional[Activity]:
11✔
448
    """Return the latest activity that was executed before the passed activity."""
449
    activities_before = [a for a in activities if a.ended_at_time <= activity.ended_at_time and a.id != activity.id]
4✔
450
    return get_latest_activity(activities_before)
4✔
451

452

453
@inject.autoparams("activity_gateway")
11✔
454
@validate_arguments(config=dict(arbitrary_types_allowed=True))
11✔
455
def revert_activity(
11✔
456
    *, activity_gateway: IActivityGateway, activity_id: str, delete_plan: bool, force: bool, metadata_only: bool
457
) -> Activity:
458
    """Revert an activity.
459

460
    Args:
461
        activity_gateway(IActivityGateway): The injected activity gateway.
462
        activity_id(str): ID of the activity to be reverted.
463
        delete_plan(bool): Delete the plan if it's not used by any other activity.
464
        force(bool): Revert the activity even if it has some downstream activities.
465
        metadata_only(bool): Only revert the metadata and don't touch generated files.
466

467
    Returns:
468
        The deleted activity.
469
    """
470
    repository = project_context.repository
4✔
471

472
    def delete_associated_plan(activity):
4✔
473
        if not delete_plan:
4✔
474
            return
4✔
475

476
        plan = activity.association.plan
2✔
477

478
        used_by_other_activities = any(a for a in get_activities(plan) if a.id != activity.id)
2✔
479
        if used_by_other_activities:
2✔
480
            return
2✔
481

482
        remove_plan(name_or_id=plan.id, force=True)
2✔
483

484
    def revert_generations(activity) -> Tuple[Set[str], Set[str]]:
4✔
485
        """Either revert each generation to an older version (created by an earlier activity) or delete it."""
486
        deleted_paths = set()
4✔
487
        updated_paths: Dict[str, str] = {}
4✔
488

489
        if metadata_only:
4✔
490
            return set(), set()
2✔
491

492
        for generation in activity.generations:
4✔
493
            path = generation.entity.path
4✔
494

495
            generator_activities = activity_gateway.get_activities_by_generation(path=path)
4✔
496
            generator_activities = [a for a in generator_activities if is_activity_valid(a) and not a.deleted]
4✔
497
            latest_generator = get_latest_activity(generator_activities)
4✔
498
            if latest_generator != activity:  # NOTE: A newer activity already generated the same path
4✔
499
                continue
2✔
500

501
            previous_generator = get_latest_activity_before(generator_activities, activity)
4✔
502

503
            if previous_generator is None:  # NOTE: The activity is the only generator
4✔
504
                # NOTE: Delete the path if there are no downstreams otherwise keep it
505
                downstream_activities = activity_gateway.get_activities_by_usage(path)
4✔
506
                if not downstream_activities:
4✔
507
                    deleted_paths.add(path)
4✔
508
                elif not force:
4✔
509
                    raise errors.ActivityDownstreamNotEmptyError(activity)
×
510
            else:  # NOTE: There is a previous generation of that path, so, revert to it
511
                previous_generation = next(g for g in previous_generator.generations if g.entity.path == path)
2✔
512
                updated_paths[path] = previous_generation.entity.checksum
2✔
513

514
        for path, previous_checksum in updated_paths.items():
4✔
515
            try:
2✔
516
                repository.copy_content_to_file(path, checksum=previous_checksum, output_path=path)
2✔
517
            except errors.FileNotFound:
×
518
                communication.warn(f"Cannot revert '{path}' to a previous version, will keep the current version")
×
519

520
        for path in deleted_paths:
4✔
521
            try:
4✔
522
                os.unlink(project_context.path / path)
4✔
523
            except OSError:
×
524
                communication.warn(f"Cannot delete '{path}'")
×
525

526
        return deleted_paths, set(updated_paths.keys())
4✔
527

528
    activity = activity_gateway.get_by_id(activity_id)
4✔
529

530
    if activity is None:
4✔
531
        raise errors.ParameterError(f"Cannot find activity with ID '{activity}'")
×
532
    if activity.deleted:
4✔
533
        raise errors.ParameterError(f"Activity with ID '{activity}' is already deleted")
×
534

535
    # NOTE: The order of removal is important here so don't change it
536
    delete_associated_plan(activity)
4✔
537
    revert_generations(activity)
4✔
538
    activity_gateway.remove(activity, force=force)
4✔
539
    # NOTE: Delete the activity after processing metadata or otherwise we won't see the activity as the latest generator
540
    activity.delete()
4✔
541

542
    return activity
4✔
543

544

545
def is_activity_valid(activity: Activity) -> bool:
11✔
546
    """Return whether this plan has not been deleted.
547

548
    Args:
549
        activity(Activity): The Activity whose Plan should be checked.
550

551
    Returns:
552
        bool: True if the activities' Plan is still valid, False otherwise.
553
    """
554
    return not is_plan_removed(plan=activity.association.plan)
6✔
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