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

knowledgepixels / nanodash / 18744589677

23 Oct 2025 09:55AM UTC coverage: 12.859% (-0.8%) from 13.703%
18744589677

push

github

tkuhn
feat(SpacePage): Show list of maintained resources

448 of 4396 branches covered (10.19%)

Branch coverage included in aggregate %.

1174 of 8218 relevant lines covered (14.29%)

0.64 hits per line

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

2.24
src/main/java/com/knowledgepixels/nanodash/Space.java
1
package com.knowledgepixels.nanodash;
2

3
import static com.knowledgepixels.nanodash.Utils.vf;
4

5
import java.io.Serializable;
6
import java.util.ArrayList;
7
import java.util.Calendar;
8
import java.util.Collections;
9
import java.util.HashMap;
10
import java.util.HashSet;
11
import java.util.List;
12
import java.util.Map;
13
import java.util.Set;
14

15
import org.eclipse.rdf4j.model.IRI;
16
import org.eclipse.rdf4j.model.Literal;
17
import org.eclipse.rdf4j.model.Statement;
18
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
19
import org.eclipse.rdf4j.model.vocabulary.OWL;
20
import org.nanopub.Nanopub;
21
import org.nanopub.extra.services.ApiResponse;
22
import org.nanopub.extra.services.ApiResponseEntry;
23
import org.nanopub.extra.services.QueryRef;
24
import org.nanopub.vocabulary.NTEMPLATE;
25
import org.slf4j.Logger;
26
import org.slf4j.LoggerFactory;
27

28
import com.github.jsonldjava.shaded.com.google.common.collect.Ordering;
29
import com.google.common.collect.ArrayListMultimap;
30
import com.google.common.collect.Multimap;
31
import com.knowledgepixels.nanodash.template.Template;
32
import com.knowledgepixels.nanodash.template.TemplateData;
33

34
import jakarta.xml.bind.DatatypeConverter;
35

36
/**
37
 * Class representing a "Space", which can be any kind of collaborative unit, like a project, group, or event.
38
 */
39
public class Space implements Serializable {
40

41
    private static final Logger logger = LoggerFactory.getLogger(Space.class);
3✔
42

43
    /**
44
     * The predicate to assign the admins of the space.
45
     */
46
    public static final IRI HAS_ADMIN = vf.createIRI("https://w3id.org/kpxl/gen/terms/hasAdmin");
4✔
47

48
    /**
49
     * The predicate for pinned templates in the space.
50
     */
51
    public static final IRI HAS_PINNED_TEMPLATE = vf.createIRI("https://w3id.org/kpxl/gen/terms/hasPinnedTemplate");
4✔
52

53
    /**
54
     * The predicate for pinned queries in the space.
55
     */
56
    public static final IRI HAS_PINNED_QUERY = vf.createIRI("https://w3id.org/kpxl/gen/terms/hasPinnedQuery");
4✔
57

58
    private static List<Space> spaceList;
59
    private static Map<String, List<Space>> spaceListByType;
60
    private static Map<String, Space> spacesByCoreInfo = new HashMap<>();
4✔
61
    private static Map<String, Space> spacesById;
62
    private static Map<Space, Set<Space>> subspaceMap;
63
    private static Map<Space, Set<Space>> superspaceMap;
64
    private static boolean loaded = false;
3✔
65

66
    /**
67
     * Refresh the list of spaces from the API response.
68
     *
69
     * @param resp The API response containing space data.
70
     */
71
    public static synchronized void refresh(ApiResponse resp) {
72
        spaceList = new ArrayList<>();
×
73
        spaceListByType = new HashMap<>();
×
74
        Map<String, Space> prevSpacesByCoreInfoPrev = spacesByCoreInfo;
×
75
        spacesByCoreInfo = new HashMap<>();
×
76
        spacesById = new HashMap<>();
×
77
        subspaceMap = new HashMap<>();
×
78
        superspaceMap = new HashMap<>();
×
79
        for (ApiResponseEntry entry : resp.getData()) {
×
80
            Space space = new Space(entry);
×
81
            Space prevSpace = prevSpacesByCoreInfoPrev.get(space.getCoreInfoString());
×
82
            if (prevSpace != null) space = prevSpace;
×
83
            spaceList.add(space);
×
84
            spaceListByType.computeIfAbsent(space.getType(), k -> new ArrayList<>()).add(space);
×
85
            spacesByCoreInfo.put(space.getCoreInfoString(), space);
×
86
            spacesById.put(space.getId(), space);
×
87
        }
×
88
        for (Space space : spaceList) {
×
89
            Space superSpace = space.getIdSuperspace();
×
90
            if (superSpace == null) continue;
×
91
            subspaceMap.computeIfAbsent(superSpace, k -> new HashSet<>()).add(space);
×
92
            superspaceMap.computeIfAbsent(space, k -> new HashSet<>()).add(superSpace);
×
93
        }
×
94
        loaded = true;
×
95
    }
×
96

97
    /**
98
     * Check if the spaces have been loaded.
99
     *
100
     * @return true if loaded, false otherwise.
101
     */
102
    public static boolean isLoaded() {
103
        return loaded;
×
104
    }
105

106
    /**
107
     * Ensure that the spaces are loaded, fetching them from the API if necessary.
108
     */
109
    public static void ensureLoaded() {
110
        if (spaceList == null) {
2!
111
            refresh(QueryApiAccess.forcedGet(new QueryRef("get-spaces")));
×
112
        }
113
    }
×
114

115
    /**
116
     * Get the list of all spaces.
117
     *
118
     * @return List of spaces.
119
     */
120
    public static List<Space> getSpaceList() {
121
        ensureLoaded();
×
122
        return spaceList;
×
123
    }
124

125
    /**
126
     * Get the list of spaces of a specific type.
127
     *
128
     * @param type The type of spaces to retrieve.
129
     * @return List of spaces of the specified type.
130
     */
131
    public static List<Space> getSpaceList(String type) {
132
        ensureLoaded();
×
133
        return spaceListByType.computeIfAbsent(type, k -> new ArrayList<>());
×
134
    }
135

136
    /**
137
     * Get a space by its id.
138
     *
139
     * @param id The id of the space.
140
     * @return The corresponding Space object, or null if not found.
141
     */
142
    public static Space get(String id) {
143
        ensureLoaded();
×
144
        return spacesById.get(id);
×
145
    }
146

147
    /**
148
     * Mark all spaces as needing a data update.
149
     */
150
    public static void refresh() {
151
        ensureLoaded();
×
152
        for (Space space : spaceList) {
×
153
            space.dataNeedsUpdate = true;
×
154
        }
×
155
    }
×
156

157
    private String id, label, rootNanopubId, type;
158
    private Nanopub rootNanopub = null;
×
159
    private SpaceData data = new SpaceData();
×
160

161
    private static class SpaceData implements Serializable {
×
162

163
        List<String> altIds = new ArrayList<>();
×
164

165
        String description = null;
×
166
        Calendar startDate, endDate;
167
        IRI defaultProvenance = null;
×
168

169
        List<IRI> admins = new ArrayList<>();
×
170
        Map<IRI, Set<SpaceMemberRole>> users = new HashMap<>();
×
171
        List<SpaceMemberRole> roles = new ArrayList<>();
×
172
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
×
173

174
        Map<String, IRI> adminPubkeyMap = new HashMap<>();
×
175
        Set<Serializable> pinnedResources = new HashSet<>();
×
176
        List<ResourceView> views = new ArrayList<>();
×
177
        Set<String> pinGroupTags = new HashSet<>();
×
178
        Map<String, Set<Serializable>> pinnedResourceMap = new HashMap<>();
×
179

180
        void addAdmin(IRI admin) {
181
            // TODO This isn't efficient for long owner lists:
182
            if (admins.contains(admin)) return;
×
183
            admins.add(admin);
×
184
            UserData ud = User.getUserData();
×
185
            for (String pubkeyhash : ud.getPubkeyhashes(admin, true)) {
×
186
                adminPubkeyMap.put(pubkeyhash, admin);
×
187
            }
×
188
        }
×
189

190
    }
191

192
    private boolean dataInitialized = false;
×
193
    private boolean dataNeedsUpdate = true;
×
194

195
    private Space(ApiResponseEntry resp) {
×
196
        this.id = resp.get("space");
×
197
        this.label = resp.get("label");
×
198
        this.type = resp.get("type");
×
199
        this.rootNanopubId = resp.get("np");
×
200
        this.rootNanopub = Utils.getAsNanopub(rootNanopubId);
×
201
        setCoreData(data);
×
202
    }
×
203

204
    /**
205
     * Get the ID of the space.
206
     *
207
     * @return The space ID.
208
     */
209
    public String getId() {
210
        return id;
×
211
    }
212

213
    /**
214
     * Get the root nanopublication ID of the space.
215
     *
216
     * @return The root nanopub ID.
217
     */
218
    public String getRootNanopubId() {
219
        return rootNanopubId;
×
220
    }
221

222
    /**
223
     * Get a string combining the space ID and root nanopub ID for core identification.
224
     *
225
     * @return The core info string.
226
     */
227
    public String getCoreInfoString() {
228
        return id + " " + rootNanopubId;
×
229
    }
230

231
    /**
232
     * Get the root nanopublication of the space.
233
     *
234
     * @return The root Nanopub object.
235
     */
236
    public Nanopub getRootNanopub() {
237
        return rootNanopub;
×
238
    }
239

240
    /**
241
     * Get the label of the space.
242
     *
243
     * @return The space label.
244
     */
245
    public String getLabel() {
246
        return label;
×
247
    }
248

249
    /**
250
     * Get the type of the space.
251
     *
252
     * @return The space type.
253
     */
254
    public String getType() {
255
        return type;
×
256
    }
257

258
    /**
259
     * Get the start date of the space.
260
     *
261
     * @return The start date as a Calendar object, or null if not set.
262
     */
263
    public Calendar getStartDate() {
264
        return data.startDate;
×
265
    }
266

267
    /**
268
     * Get the end date of the space.
269
     *
270
     * @return The end date as a Calendar object, or null if not set.
271
     */
272
    public Calendar getEndDate() {
273
        return data.endDate;
×
274
    }
275

276
    /**
277
     * Get a simplified label for the type of space by removing any namespace prefix.
278
     *
279
     * @return The simplified type label.
280
     */
281
    public String getTypeLabel() {
282
        return type.replaceFirst("^.*/", "");
×
283
    }
284

285
    /**
286
     * Get the description of the space.
287
     *
288
     * @return The description string.
289
     */
290
    public String getDescription() {
291
        return data.description;
×
292
    }
293

294
    /**
295
     * Check if the space data has been initialized.
296
     *
297
     * @return true if initialized, false otherwise.
298
     */
299
    public boolean isDataInitialized() {
300
        triggerDataUpdate();
×
301
        return dataInitialized;
×
302
    }
303

304
    /**
305
     * Get the list of admins in this space.
306
     *
307
     * @return List of admin IRIs.
308
     */
309
    public List<IRI> getAdmins() {
310
        ensureInitialized();
×
311
        return data.admins;
×
312
    }
313

314
    /**
315
     * Get the list of members in this space.
316
     *
317
     * @return List of member IRIs.
318
     */
319
    public List<IRI> getUsers() {
320
        ensureInitialized();
×
321
        List<IRI> users = new ArrayList<IRI>(data.users.keySet());
×
322
        users.sort(User.getUserData().userComparator);
×
323
        return users;
×
324
    }
325

326
    /**
327
     * Get the roles of a specific member in this space.
328
     *
329
     * @param userId The IRI of the member.
330
     * @return Set of roles assigned to the member, or null if the member is not part of this space.
331
     */
332
    public Set<SpaceMemberRole> getMemberRoles(IRI userId) {
333
        ensureInitialized();
×
334
        return data.users.get(userId);
×
335
    }
336

337
    /**
338
     * Check if a user is a member of this space.
339
     *
340
     * @param userId The IRI of the user to check.
341
     * @return true if the user is a member, false otherwise.
342
     */
343
    public boolean isMember(IRI userId) {
344
        ensureInitialized();
×
345
        return data.users.containsKey(userId);
×
346
    }
347

348
    public boolean isAdminPubkey(String pubkey) {
349
        ensureInitialized();
×
350
        return data.adminPubkeyMap.containsKey(pubkey);
×
351
    }
352

353
    /**
354
     * Get the list of pinned resources in this space.
355
     *
356
     * @return List of pinned resources.
357
     */
358
    public Set<Serializable> getPinnedResources() {
359
        ensureInitialized();
×
360
        return data.pinnedResources;
×
361
    }
362

363
    /**
364
     * Get the set of tags used for grouping pinned resources.
365
     *
366
     * @return Set of tags.
367
     */
368
    public Set<String> getPinGroupTags() {
369
        ensureInitialized();
×
370
        return data.pinGroupTags;
×
371
    }
372

373
    /**
374
     * Get a map of pinned resources grouped by their tags.
375
     *
376
     * @return Map where keys are tags and values are lists of pinned resources (Templates or GrlcQueries).
377
     */
378
    public Map<String, Set<Serializable>> getPinnedResourceMap() {
379
        ensureInitialized();
×
380
        return data.pinnedResourceMap;
×
381
    }
382

383
    /**
384
     * Get the list of views (GrlcQueries) associated with this space.
385
     *
386
     * @return List of GrlcQuery views.
387
     */
388
    public List<ResourceView> getViews() {
389
        return data.views;
×
390
    }
391

392
    /**
393
     * Get the default provenance IRI for this space.
394
     *
395
     * @return The default provenance IRI, or null if not set.
396
     */
397
    public IRI getDefaultProvenance() {
398
        return data.defaultProvenance;
×
399
    }
400

401
    /**
402
     * Get the roles defined in this space.
403
     *
404
     * @return List of roles.
405
     */
406
    public List<SpaceMemberRole> getRoles() {
407
        return data.roles;
×
408
    }
409

410
    /**
411
     * Get the super ID of the space.
412
     *
413
     * @return Always returns null. Use getIdSuperspace() instead.
414
     */
415
    public String getSuperId() {
416
        return null;
×
417
    }
418

419
    /**
420
     * Get the superspace ID.
421
     *
422
     * @return The superspace, or null if not applicable.
423
     */
424
    public Space getIdSuperspace() {
425
        if (!id.matches("https?://[^/]+/.*/[^/]*/?")) return null;
×
426
        String superId = id.replaceFirst("(https?://[^/]+/.*)/[^/]*/?", "$1");
×
427
        if (spacesById.containsKey(superId)) {
×
428
            return spacesById.get(superId);
×
429
        }
430
        return null;
×
431
    }
432

433
    /**
434
     * Get superspaces of this space.
435
     *
436
     * @return List of superspaces.
437
     */
438
    public List<Space> getSuperspaces() {
439
        if (superspaceMap.containsKey(this)) {
×
440
            List<Space> superspaces = new ArrayList<>(superspaceMap.get(this));
×
441
            Collections.sort(superspaces, Ordering.usingToString());
×
442
            return superspaces;
×
443
        }
444
        return new ArrayList<>();
×
445
    }
446

447
    /**
448
     * Get subspaces of this space.
449
     *
450
     * @return List of subspaces.
451
     */
452
    public List<Space> getSubspaces() {
453
        if (subspaceMap.containsKey(this)) {
×
454
            List<Space> subspaces = new ArrayList<>(subspaceMap.get(this));
×
455
            Collections.sort(subspaces, Ordering.usingToString());
×
456
            return subspaces;
×
457
        }
458
        return new ArrayList<>();
×
459
    }
460

461
    /**
462
     * Get subspaces of a specific type.
463
     *
464
     * @param type The type of subspaces to retrieve.
465
     * @return List of subspaces of the specified type.
466
     */
467
    public List<Space> getSubspaces(String type) {
468
        List<Space> l = new ArrayList<>();
×
469
        for (Space s : getSubspaces()) {
×
470
            if (s.getType().equals(type)) l.add(s);
×
471
        }
×
472
        return l;
×
473
    }
474

475
    /**
476
     * Get alternative IDs for the space.
477
     *
478
     * @return List of alternative IDs.
479
     */
480
    public List<String> getAltIDs() {
481
        return data.altIds;
×
482
    }
483

484
    private synchronized void ensureInitialized() {
485
        Thread thread = triggerDataUpdate();
×
486
        if (!dataInitialized && thread != null) {
×
487
            try {
488
                thread.join();
×
489
            } catch (InterruptedException ex) {
×
490
                logger.error("failed to join thread", ex);
×
491
            }
×
492
        }
493
    }
×
494

495
    private synchronized Thread triggerDataUpdate() {
496
        if (dataNeedsUpdate) {
×
497
            Thread thread = new Thread(() -> {
×
498
                try {
499
                    SpaceData newData = new SpaceData();
×
500
                    setCoreData(newData);
×
501

502
                    newData.roles.add(SpaceMemberRole.ADMIN_ROLE);
×
503
                    newData.roleMap.put(SpaceMemberRole.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
504

505
                    // TODO Improve this:
506
                    Multimap<String, String> spaceIds = ArrayListMultimap.create();
×
507
                    Multimap<String, String> resourceIds = ArrayListMultimap.create();
×
508
                    spaceIds.put("space", id);
×
509
                    resourceIds.put("resource", id);
×
510
                    for (String id : newData.altIds) {
×
511
                        spaceIds.put("space", id);
×
512
                        resourceIds.put("resource", id);
×
513
                    }
×
514

515
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-admins", spaceIds)).getData()) {
×
516
                        String pubkeyhash = r.get("pubkey");
×
517
                        if (newData.adminPubkeyMap.containsKey(pubkeyhash)) {
×
518
                            IRI adminId = Utils.vf.createIRI(r.get("admin"));
×
519
                            newData.addAdmin(adminId);
×
520
                            newData.users.computeIfAbsent(adminId, (k) -> new HashSet<>()).add(SpaceMemberRole.ADMIN_ROLE);
×
521
                        }
522
                    }
×
523
                    newData.admins.sort(User.getUserData().userComparator);
×
524

525
                    Multimap<String, String> getSpaceMemberParams = ArrayListMultimap.create(spaceIds);
×
526

527
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-space-member-roles", spaceIds)).getData()) {
×
528
                        if (!newData.adminPubkeyMap.containsKey(r.get("pubkey"))) continue;
×
529
                        SpaceMemberRole role = new SpaceMemberRole(r);
×
530
                        newData.roles.add(role);
×
531

532
                        // TODO Handle cases of overlapping properties:
533
                        for (IRI p : role.getRegularProperties()) newData.roleMap.put(p, role);
×
534
                        for (IRI p : role.getInverseProperties()) newData.roleMap.put(p, role);
×
535

536
                        role.addRoleParams(getSpaceMemberParams);
×
537
                    }
×
538

539
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-space-members", getSpaceMemberParams)).getData()) {
×
540
                        IRI memberId = Utils.vf.createIRI(r.get("member"));
×
541
                        SpaceMemberRole role = newData.roleMap.get(Utils.vf.createIRI(r.get("role")));
×
542
                        newData.users.computeIfAbsent(memberId, (k) -> new HashSet<>()).add(role);
×
543
                    }
×
544

545
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-pinned-templates", spaceIds)).getData()) {
×
546
                        if (!newData.adminPubkeyMap.containsKey(r.get("pubkey"))) continue;
×
547
                        Template t = TemplateData.get().getTemplate(r.get("template"));
×
548
                        if (t == null) continue;
×
549
                        newData.pinnedResources.add(t);
×
550
                        String tag = r.get("tag");
×
551
                        if (tag != null && !tag.isEmpty()) {
×
552
                            newData.pinGroupTags.add(r.get("tag"));
×
553
                            newData.pinnedResourceMap.computeIfAbsent(tag, k -> new HashSet<>()).add(TemplateData.get().getTemplate(r.get("template")));
×
554
                        }
555
                    }
×
556
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-pinned-queries", spaceIds)).getData()) {
×
557
                        if (!newData.adminPubkeyMap.containsKey(r.get("pubkey"))) continue;
×
558
                        GrlcQuery query = GrlcQuery.get(r.get("query"));
×
559
                        if (query == null) continue;
×
560
                        newData.pinnedResources.add(query);
×
561
                        String tag = r.get("tag");
×
562
                        if (tag != null && !tag.isEmpty()) {
×
563
                            newData.pinGroupTags.add(r.get("tag"));
×
564
                            newData.pinnedResourceMap.computeIfAbsent(tag, k -> new HashSet<>()).add(query);
×
565
                        }
566
                    }
×
567
                    for (ApiResponseEntry r : QueryApiAccess.get(new QueryRef("get-view-displays", resourceIds)).getData()) {
×
568
                        if (!newData.adminPubkeyMap.containsKey(r.get("pubkey"))) continue;
×
569
                        ResourceView view = ResourceView.get(r.get("view"));
×
570
                        if (view == null) continue;
×
571
                        newData.views.add(view);
×
572
                    }
×
573
                    data = newData;
×
574
                    dataInitialized = true;
×
575
                } catch (Exception ex) {
×
576
                    logger.error("Error while trying to update space data: {}", ex);
×
577
                }
×
578
            });
×
579
            thread.start();
×
580
            dataNeedsUpdate = false;
×
581
            return thread;
×
582
        }
583
        return null;
×
584
    }
585

586
    private void setCoreData(SpaceData data) {
587
        for (Statement st : rootNanopub.getAssertion()) {
×
588
            if (st.getSubject().stringValue().equals(getId())) {
×
589
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
×
590
                    data.altIds.add(objIri.stringValue());
×
591
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
×
592
                    data.description = st.getObject().stringValue();
×
593
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
×
594
                    try {
595
                        data.startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
×
596
                    } catch (IllegalArgumentException ex) {
×
597
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
598
                    }
×
599
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
×
600
                    try {
601
                        data.endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
×
602
                    } catch (IllegalArgumentException ex) {
×
603
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
604
                    }
×
605
                } else if (st.getPredicate().equals(HAS_ADMIN) && st.getObject() instanceof IRI obj) {
×
606
                    data.addAdmin(obj);
×
607
                } else if (st.getPredicate().equals(HAS_PINNED_TEMPLATE) && st.getObject() instanceof IRI obj) {
×
608
                    data.pinnedResources.add(TemplateData.get().getTemplate(obj.stringValue()));
×
609
                } else if (st.getPredicate().equals(HAS_PINNED_QUERY) && st.getObject() instanceof IRI obj) {
×
610
                    data.pinnedResources.add(GrlcQuery.get(obj.stringValue()));
×
611
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
×
612
                    data.defaultProvenance = obj;
×
613
                }
614
            } else if (st.getPredicate().equals(NTEMPLATE.HAS_TAG) && st.getObject() instanceof Literal l) {
×
615
                data.pinGroupTags.add(l.stringValue());
×
616
                Set<Serializable> list = data.pinnedResourceMap.get(l.stringValue());
×
617
                if (list == null) {
×
618
                    list = new HashSet<>();
×
619
                    data.pinnedResourceMap.put(l.stringValue(), list);
×
620
                }
621
                list.add(TemplateData.get().getTemplate(st.getSubject().stringValue()));
×
622
            }
623
        }
×
624
    }
×
625

626
    @Override
627
    public String toString() {
628
        return id;
×
629
    }
630

631
}
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