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

SwissDataScienceCenter / renku-python / 6479334815

11 Oct 2023 07:27AM UTC coverage: 85.511% (+0.04%) from 85.469%
6479334815

Pull #3636

github-actions

web-flow
Merge 67f02aba2 into f4b648019
Pull Request #3636: fix(cli): do not start a session when in detached HEAD state

8 of 8 new or added lines in 2 files covered. (100.0%)

4 existing lines in 4 files now uncovered.

26280 of 30733 relevant lines covered (85.51%)

4.88 hits per line

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

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

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

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

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

36

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

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

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

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

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

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

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

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

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

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

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

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

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

90

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

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

103
    graph = networkx.DiGraph()
6✔
104

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

217
    connect_nodes_based_on_dependencies()
6✔
218

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

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

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

228
    return graph
6✔
229

230

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

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

237

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

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

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

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

250

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

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

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

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

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

274

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

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

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

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

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

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

306
        return is_same or is_parent
4✔
307

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

313
        return False
4✔
314

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

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

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

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

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

345

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

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

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

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

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

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

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

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

386

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

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

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

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

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

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

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

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

417
        relevant_activities[outputs] = activity
4✔
418

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

421

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

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

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

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

440

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

445

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

451

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

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

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

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

475
        plan = activity.association.plan
2✔
476

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

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

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

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

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

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

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

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

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

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

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

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

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

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

541
    return activity
4✔
542

543

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

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

550
    Returns:
551
        bool: True if the activities' Plan is still valid, False otherwise.
552
    """
553
    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

© 2025 Coveralls, Inc