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

knowledgepixels / nanopub-query / 17118767678

21 Aug 2025 06:16AM UTC coverage: 50.058% (+1.2%) from 48.904%
17118767678

push

github

tkuhn
Remove obsolete POSTing of new nanopubs to port 9300 feature

239 of 490 branches covered (48.78%)

Branch coverage included in aggregate %.

631 of 1248 relevant lines covered (50.56%)

2.49 hits per line

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

0.0
src/main/java/com/knowledgepixels/query/MainVerticle.java
1
package com.knowledgepixels.query;
2

3
import java.io.InputStream;
4
import java.net.URLEncoder;
5
import java.util.ArrayList;
6
import java.util.Collections;
7
import java.util.List;
8
import java.util.Map.Entry;
9
import java.util.Scanner;
10
import java.util.concurrent.Executors;
11
import java.util.concurrent.TimeUnit;
12

13
import org.eclipse.rdf4j.model.Value;
14

15
import com.github.jsonldjava.shaded.com.google.common.base.Charsets;
16

17
import io.micrometer.prometheus.PrometheusMeterRegistry;
18
import io.vertx.core.AbstractVerticle;
19
import io.vertx.core.Future;
20
import io.vertx.core.MultiMap;
21
import io.vertx.core.Promise;
22
import io.vertx.core.http.HttpClient;
23
import io.vertx.core.http.HttpClientOptions;
24
import io.vertx.core.http.HttpMethod;
25
import io.vertx.core.http.HttpServer;
26
import io.vertx.core.http.HttpServerResponse;
27
import io.vertx.core.http.PoolOptions;
28
import io.vertx.ext.web.Router;
29
import io.vertx.ext.web.RoutingContext;
30
import io.vertx.ext.web.handler.CorsHandler;
31
import io.vertx.ext.web.handler.StaticHandler;
32
import io.vertx.ext.web.proxy.handler.ProxyHandler;
33
import io.vertx.httpproxy.HttpProxy;
34
import io.vertx.httpproxy.ProxyContext;
35
import io.vertx.httpproxy.ProxyInterceptor;
36
import io.vertx.httpproxy.ProxyRequest;
37
import io.vertx.httpproxy.ProxyResponse;
38
import io.vertx.micrometer.PrometheusScrapingHandler;
39
import io.vertx.micrometer.backends.BackendRegistries;
40

41
/**
42
 * Main verticle that coordinates the incoming HTTP requests.
43
 */
44
public class MainVerticle extends AbstractVerticle {
×
45

46
    private static String css = null;
×
47

48
    /**
49
     * Start the main verticle.
50
     *
51
     * @param startPromise the promise to complete when the verticle is started
52
     * @throws Exception if an error occurs during startup
53
     */
54
    @Override
55
    public void start(Promise<Void> startPromise) throws Exception {
56
        HttpClient httpClient = vertx.createHttpClient(
×
57
                new HttpClientOptions()
58
                        .setConnectTimeout(1000).setIdleTimeoutUnit(TimeUnit.SECONDS)
×
59
                        .setIdleTimeout(60).setReadIdleTimeout(60).setWriteIdleTimeout(60),
×
60
                new PoolOptions().setHttp1MaxSize(200).setHttp2MaxSize(200)
×
61
        );
62

63
        HttpServer proxyServer = vertx.createHttpServer();
×
64
        Router proxyRouter = Router.router(vertx);
×
65
        proxyRouter.route().handler(CorsHandler.create().addRelativeOrigin(".*"));
×
66

67
        // Metrics
68
        final var metricsHttpServer = vertx.createHttpServer();
×
69
        final var metricsRouter = Router.router(vertx);
×
70
        metricsHttpServer.requestHandler(metricsRouter).listen(9394);
×
71

72
        final var metricsRegistry = (PrometheusMeterRegistry) BackendRegistries.getDefaultNow();
×
73
        final var collector = new MetricsCollector(metricsRegistry);
×
74
        metricsRouter.route("/metrics").handler(PrometheusScrapingHandler.create(metricsRegistry));
×
75
        // ----------
76
        // This part is only used if the redirection is not done through Nginx.
77
        // See nginx.conf and this bug report: https://github.com/eclipse-rdf4j/rdf4j/discussions/5120
78
        HttpProxy rdf4jProxy = HttpProxy.reverseProxy(httpClient);
×
79
        String proxy = Utils.getEnvString("RDF4J_PROXY_HOST", "rdf4j");
×
80
        int proxyPort = Utils.getEnvInt("RDF4J_PROXY_PORT", 8080);
×
81
        rdf4jProxy.origin(proxyPort, proxy);
×
82

83
        rdf4jProxy.addInterceptor(new ProxyInterceptor() {
×
84

85
            @Override
86
            public Future<ProxyResponse> handleProxyRequest(ProxyContext context) {
87
                ProxyRequest request = context.request();
×
88
                request.setURI(request.getURI().replaceAll("/", "_").replaceFirst("^_repo_", "/rdf4j-server/repositories/"));
×
89
                // For later to try to get HTML tables out:
90
//                                if (request.headers().get("Accept") == null) {
91
//                                        request.putHeader("Accept", "text/html");
92
//                                }
93
//                                request.putHeader("Accept", "application/json");
94
                return ProxyInterceptor.super.handleProxyRequest(context);
×
95
            }
96

97
            @Override
98
            public Future<Void> handleProxyResponse(ProxyContext context) {
99
                ProxyResponse resp = context.response();
×
100
                resp.putHeader("Access-Control-Allow-Origin", "*");
×
101
                resp.putHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
×
102
                // For later to try to get HTML tables out:
103
//                                String acceptHeader = context.request().headers().get("Accept");
104
//                                if (acceptHeader != null && acceptHeader.contains("text/html")) {
105
//                                        resp.putHeader("Content-Type", "text/html");
106
//                                        resp.setBody(Body.body(Buffer.buffer("<html><body><strong>test</strong></body></html>")));
107
//                                }
108
                return ProxyInterceptor.super.handleProxyResponse(context);
×
109
            }
110

111
        });
112
        // ----------
113

114
        proxyRouter.route(HttpMethod.GET, "/repo").handler(req -> handleRedirect(req, "/repo"));
×
115
        proxyRouter.route(HttpMethod.GET, "/repo/*").handler(ProxyHandler.create(rdf4jProxy));
×
116
        proxyRouter.route(HttpMethod.POST, "/repo/*").handler(ProxyHandler.create(rdf4jProxy));
×
117
        proxyRouter.route(HttpMethod.HEAD, "/repo/*").handler(ProxyHandler.create(rdf4jProxy));
×
118
        proxyRouter.route(HttpMethod.OPTIONS, "/repo/*").handler(ProxyHandler.create(rdf4jProxy));
×
119
        proxyRouter.route(HttpMethod.GET, "/tools/*").handler(req -> {
×
120
            final String yasguiPattern = "^/tools/([a-zA-Z0-9-_]+)(/([a-zA-Z0-9-_]+))?/yasgui\\.html$";
×
121
            if (req.normalizedPath().matches(yasguiPattern)) {
×
122
                String repo = req.normalizedPath().replaceFirst(yasguiPattern, "$1$2");
×
123
                req.response()
×
124
                        .putHeader("content-type", "text/html")
×
125
                        .end("<!DOCTYPE html>\n"
×
126
                                + "<html lang=\"en\">\n"
127
                                + "<head>\n"
128
                                + "<meta charset=\"utf-8\">\n"
129
                                + "<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n"
130
                                + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
131
                                + "<title>Nanopub Query SPARQL Editor for repository: " + repo + "</title>\n"
132
                                + "<link rel=\"stylesheet\" href=\"/style.css\">\n"
133
                                + "<link href='https://cdn.jsdelivr.net/yasgui/2.6.1/yasgui.min.css' rel='stylesheet' type='text/css'/>\n"
134
                                + "<style>.yasgui .endpointText {display:none !important;}</style>\n"
135
                                + "<script type=\"text/javascript\">localStorage.clear();</script>\n"
136
                                + "</head>\n"
137
                                + "<body>\n"
138
                                + "<h3>Nanopub Query SPARQL Editor for repository: " + repo + "</h3>\n"
139
                                + "<div id='yasgui'></div>\n"
140
                                + "<script src='https://cdn.jsdelivr.net/yasgui/2.6.1/yasgui.min.js'></script>\n"
141
                                + "<script type=\"text/javascript\">\n"
142
                                + "var yasgui = YASGUI(document.getElementById(\"yasgui\"), {\n"
143
                                + "  yasqe:{sparql:{endpoint:'/repo/" + repo + "'},value:'" + Utils.defaultQuery.replaceAll("\n", "\\\\n") + "'}\n"
×
144
                                + "});\n"
145
                                + "</script>\n"
146
                                + "</body>\n"
147
                                + "</html>");
148
            } else {
×
149
                req.response()
×
150
                        .putHeader("content-type", "text/plain")
×
151
                        .setStatusCode(404)
×
152
                        .end("not found");
×
153
            }
154
        });
×
155
        proxyRouter.route(HttpMethod.GET, "/page").handler(req -> handleRedirect(req, "/page"));
×
156
        proxyRouter.route(HttpMethod.GET, "/page/*").handler(req -> {
×
157
            final String pagePattern = "^/page/([a-zA-Z0-9-_]+)(/([a-zA-Z0-9-_]+))?$";
×
158
            if (req.normalizedPath().matches(pagePattern)) {
×
159
                String repo = req.normalizedPath().replaceFirst(pagePattern, "$1$2");
×
160
                req.response()
×
161
                        .putHeader("content-type", "text/html")
×
162
                        .end("<!DOCTYPE html>\n"
×
163
                                + "<html lang=\"en\">\n"
164
                                + "<head>\n"
165
                                + "<meta charset=\"utf-8\">\n"
166
                                + "<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n"
167
                                + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
168
                                + "<title>Nanopub Query repo: " + repo + "</title>\n"
169
                                + "<link rel=\"stylesheet\" href=\"/style.css\">\n"
170
                                + "</head>\n"
171
                                + "<body>\n"
172
                                + "<h3>Nanopub Query repo: " + repo + "</h3>\n"
173
                                + "<p>Endpoint: <a href=\"/repo/" + repo + "\">/repo/" + repo + "</a></p>"
174
                                + "<p>YASGUI: <a href=\"/tools/" + repo + "/yasgui.html\">/tools/" + repo + "/yasgui.hml</a></p>"
175
                                + "</body>\n"
176
                                + "</html>");
177
            } else {
×
178
                req.response()
×
179
                        .putHeader("content-type", "text/plain")
×
180
                        .setStatusCode(404)
×
181
                        .end("not found");
×
182
            }
183
        });
×
184
        proxyRouter.route(HttpMethod.GET, "/").handler(req -> {
×
185
            String repos = "";
×
186
            List<String> repoList = new ArrayList<>(TripleStore.get().getRepositoryNames());
×
187
            Collections.sort(repoList);
×
188
            for (String s : repoList) {
×
189
                if (s.startsWith("pubkey_") || s.startsWith("type_")) continue;
×
190
                repos += "<li><code><a href=\"/page/" + s + "\">" + s + "</a></code></li>";
×
191
            }
×
192
            String pinnedApisValue = Utils.getEnvString("NANOPUB_QUERY_PINNED_APIS", "");
×
193
            String[] pinnedApis = pinnedApisValue.split(" ");
×
194
            String pinnedApiLinks = "";
×
195
            if (!pinnedApisValue.isEmpty()) {
×
196
                for (String s : pinnedApis) {
×
197
                    pinnedApiLinks = pinnedApiLinks + "<li><a href=\"openapi/?url=spec/" + s + "%3Fapi-version=latest\">" + s.replaceFirst("^.*/", "") + "</a></li>";
×
198
                }
199
                pinnedApiLinks = "<p>Pinned APIs:</p>\n" +
×
200
                        "<ul>\n" +
201
                        pinnedApiLinks +
202
                        "</ul>\n";
203
            }
204
            req.response()
×
205
                    .putHeader("content-type", "text/html")
×
206
                    .end("<!DOCTYPE html>\n"
×
207
                            + "<html lang='en'>\n"
208
                            + "<head>\n"
209
                            + "<title>Nanopub Query</title>\n"
210
                            + "<meta charset='utf-8'>\n"
211
                            + "<link rel=\"stylesheet\" href=\"/style.css\">\n"
212
                            + "</head>\n"
213
                            + "<body>\n"
214
                            + "<h1>Nanopub Query</h1>"
215
                            + "<p>General repos:</p>"
216
                            + "<ul>" + repos + "</ul>"
217
                            + "<p>Specific repos:</p>"
218
                            + "<ul>"
219
                            + "<li><a href=\"/pubkeys\">Pubkey Repos</a></li>"
220
                            + "<li><a href=\"/types\">Type Repos</a></li>"
221
                            + "</ul>"
222
                            + pinnedApiLinks
223
                            + "</body>\n"
224
                            + "</html>");
225
        });
×
226
        proxyRouter.route(HttpMethod.GET, "/pubkeys").handler(req -> {
×
227
            String repos = "";
×
228
            List<String> repoList = new ArrayList<>(TripleStore.get().getRepositoryNames());
×
229
            Collections.sort(repoList);
×
230
            for (String s : repoList) {
×
231
                if (!s.startsWith("pubkey_")) continue;
×
232
                String hash = s.replaceFirst("^([a-zA-Z0-9-]+)_([a-zA-Z0-9-_]+)$", "$2");
×
233
                Value hashObj = Utils.getObjectForHash(hash);
×
234
                String label;
235
                if (hashObj == null) {
×
236
                    label = "";
×
237
                } else {
238
                    label = " (" + Utils.getShortPubkeyName(hashObj.stringValue()) + ")";
×
239
                }
240
                s = s.replaceFirst("^([a-zA-Z0-9-]+)_([a-zA-Z0-9-_]+)$", "$1/$2");
×
241
                repos += "<li><code><a href=\"/page/" + s + "\">" + s + "</a>" + label + "</code></li>";
×
242
            }
×
243
            req.response()
×
244
                    .putHeader("content-type", "text/html")
×
245
                    .end("<!DOCTYPE html>\n"
×
246
                            + "<html lang='en'>\n"
247
                            + "<head>\n"
248
                            + "<title>Nanopub Query: Pubkey Repos</title>\n"
249
                            + "<meta charset='utf-8'>\n"
250
                            + "<link rel=\"stylesheet\" href=\"/style.css\">\n"
251
                            + "</head>\n"
252
                            + "<body>\n"
253
                            + "<h3>Pubkey Repos</h3>"
254
                            + "<p>Repos:</p>"
255
                            + "<ul>" + repos + "</ul>"
256
                            + "</body>\n"
257
                            + "</html>");
258
        });
×
259
        proxyRouter.route(HttpMethod.GET, "/types").handler(req -> {
×
260
            String repos = "";
×
261
            List<String> repoList = new ArrayList<>(TripleStore.get().getRepositoryNames());
×
262
            Collections.sort(repoList);
×
263
            for (String s : repoList) {
×
264
                if (!s.startsWith("type_")) continue;
×
265
                String hash = s.replaceFirst("^([a-zA-Z0-9-]+)_([a-zA-Z0-9-_]+)$", "$2");
×
266
                Value hashObj = Utils.getObjectForHash(hash);
×
267
                String label;
268
                if (hashObj == null) {
×
269
                    label = "";
×
270
                } else {
271
                    label = " (" + hashObj.stringValue() + ")";
×
272
                }
273
                s = s.replaceFirst("^([a-zA-Z0-9-]+)_([a-zA-Z0-9-_]+)$", "$1/$2");
×
274
                repos += "<li><code><a href=\"/page/" + s + "\">" + s + "</a>" + label + "</code></li>";
×
275
            }
×
276
            req.response()
×
277
                    .putHeader("content-type", "text/html")
×
278
                    .end("<!DOCTYPE html>\n"
×
279
                            + "<html lang='en'>\n"
280
                            + "<head>\n"
281
                            + "<title>Nanopub Query: Type Repos</title>\n"
282
                            + "<meta charset='utf-8'>\n"
283
                            + "<link rel=\"stylesheet\" href=\"/style.css\">\n"
284
                            + "</head>\n"
285
                            + "<body>\n"
286
                            + "<h3>Type Repos</h3>"
287
                            + "<p>Repos:</p>"
288
                            + "<ul>" + repos + "</ul>"
289
                            + "</body>\n"
290
                            + "</html>");
291
        });
×
292
        proxyRouter.route(HttpMethod.GET, "/style.css").handler(req -> {
×
293
            if (css == null) {
×
294
                css = getResourceAsString("style.css");
×
295
            }
296
            req.response().end(css);
×
297
        });
×
298

299
        proxyRouter.route(HttpMethod.GET, "/grlc-spec/*").handler(req -> {
×
300
            GrlcSpecPage gsp = new GrlcSpecPage(req.normalizedPath(), req.queryParams());
×
301
            String spec = gsp.getSpec();
×
302
            if (spec == null) {
×
303
                req.response().setStatusCode(404).end("query definition not found / not valid");
×
304
            } else {
305
                req.response().putHeader("content-type", "text/yaml").end(spec);
×
306
            }
307
        });
×
308

309
        proxyRouter.route(HttpMethod.GET, "/openapi/spec/*").handler(req -> {
×
310
            OpenApiSpecPage osp = new OpenApiSpecPage(req.normalizedPath(), req.queryParams());
×
311
            String spec = osp.getSpec();
×
312
            if (spec == null) {
×
313
                req.response().setStatusCode(404).end("query definition not found / not valid");
×
314
            } else {
315
                req.response().putHeader("content-type", "text/yaml").end(spec);
×
316
            }
317
        });
×
318

319
        proxyRouter.route("/openapi/*").handler(StaticHandler.create("com/knowledgepixels/query/swagger"));
×
320

321
        HttpProxy grlcProxy = HttpProxy.reverseProxy(httpClient);
×
322
        grlcProxy.origin(80, "grlc");
×
323
        grlcProxy.addInterceptor(new ProxyInterceptor() {
×
324

325
            @Override
326
            public Future<ProxyResponse> handleProxyRequest(ProxyContext context) {
327
                final String apiPattern = "^/api/(RA[a-zA-Z0-9-_]{43})/([a-zA-Z0-9-_]+)([?].*)?$";
×
328
                if (context.request().getURI().matches(apiPattern)) {
×
329
                    String artifactCode = context.request().getURI().replaceFirst(apiPattern, "$1");
×
330
                    String queryName = context.request().getURI().replaceFirst(apiPattern, "$2");
×
331
                    String grlcUrlParams = "";
×
332
                    String grlcSpecUrlParams = "";
×
333
                    MultiMap pm = context.request().proxiedRequest().params();
×
334
                    for (Entry<String, String> e : pm) {
×
335
                        if (e.getKey().equals("api-version")) {
×
336
                            grlcSpecUrlParams += "&" + e.getKey() + "=" + URLEncoder.encode(e.getValue(), Charsets.UTF_8);
×
337
                        } else {
338
                            grlcUrlParams += "&" + e.getKey() + "=" + URLEncoder.encode(e.getValue(), Charsets.UTF_8);
×
339
                        }
340
                    }
×
341
                    String url = "/api-url/" + queryName +
×
342
                            "?specUrl=" + URLEncoder.encode(GrlcSpecPage.nanopubQueryUrl + "grlc-spec/" + artifactCode + "/?" +
×
343
                            grlcSpecUrlParams, Charsets.UTF_8) + grlcUrlParams;
344
                    context.request().setURI(url);
×
345
                }
346
                return context.sendRequest();
×
347
            }
348

349
            @Override
350
            public Future<Void> handleProxyResponse(ProxyContext context) {
351
                // To avoid double entries:
352
                context.response().headers().remove("Access-Control-Allow-Origin");
×
353
                return context.sendResponse();
×
354
            }
355

356
        });
357

358
        proxyServer.requestHandler(req -> {
×
359
            applyGlobalHeaders(req.response());
×
360
            proxyRouter.handle(req);
×
361
        });
×
362
        proxyServer.listen(9393);
×
363

364
        proxyRouter.route("/api/*").handler(ProxyHandler.create(grlcProxy));
×
365
        proxyRouter.route("/static/*").handler(ProxyHandler.create(grlcProxy));
×
366

367
        // Periodic metrics update
368
        vertx.setPeriodic(1000, id -> collector.updateMetrics());
×
369

370

371
        new Thread(() -> {
×
372
            try {
373
                var status = StatusController.get().initialize();
×
374
                System.err.println("Current state: " + status.state + ", last committed counter: " + status.loadCounter);
×
375
                if (status.state == StatusController.State.LAUNCHING || status.state == StatusController.State.LOADING_INITIAL) {
×
376
                    // Do the initial nanopublication loading
377
                    StatusController.get().setLoadingInitial(status.loadCounter);
×
378
                    // Fall back to local nanopub loading if the local files are present
379
                    if (!LocalNanopubLoader.init()) {
×
380
                        JellyNanopubLoader.loadInitial(status.loadCounter);
×
381
                    } else {
382
                        System.err.println("Local nanopublication loading finished");
×
383
                    }
384
                    StatusController.get().setReady();
×
385
                } else {
386
                    System.err.println("Initial load is already done");
×
387
                    StatusController.get().setReady();
×
388
                }
389
            } catch (Exception ex) {
×
390
                ex.printStackTrace();
×
391
                System.err.println("Initial load failed, terminating...");
×
392
                Runtime.getRuntime().exit(1);
×
393
            }
×
394

395
            // Start periodic nanopub loading
396
            System.err.println("Starting periodic nanopub loading...");
×
397
            var executor = Executors.newSingleThreadScheduledExecutor();
×
398
            executor.scheduleWithFixedDelay(
×
399
                    JellyNanopubLoader::loadUpdates,
400
                    JellyNanopubLoader.UPDATES_POLL_INTERVAL,
401
                    JellyNanopubLoader.UPDATES_POLL_INTERVAL,
402
                    TimeUnit.MILLISECONDS
403
            );
404
        }).start();
×
405

406
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
×
407
            try {
408
                System.err.println("Gracefully shutting down...");
×
409
                TripleStore.get().shutdownRepositories();
×
410
                vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
×
411
                System.err.println("Graceful shutdown completed");
×
412
            } catch (Exception ex) {
×
413
                System.err.println("Graceful shutdown failed");
×
414
                ex.printStackTrace();
×
415
            }
×
416
        }));
×
417
    }
×
418

419
    private String getResourceAsString(String file) {
420
        InputStream is = getClass().getClassLoader().getResourceAsStream("com/knowledgepixels/query/" + file);
×
421
        try (Scanner s = new Scanner(is).useDelimiter("\\A")) {
×
422
            String fileContent = s.hasNext() ? s.next() : "";
×
423
            return fileContent;
×
424
        }
425
    }
426

427
    private static void handleRedirect(RoutingContext req, String path) {
428
        String queryString = "";
×
429
        if (!req.queryParam("query").isEmpty())
×
430
            queryString = "?query=" + URLEncoder.encode(req.queryParam("query").get(0), Charsets.UTF_8);
×
431
        if (req.queryParam("for-type").size() == 1) {
×
432
            String type = req.queryParam("for-type").get(0);
×
433
            req.response().putHeader("location", path + "/type/" + Utils.createHash(type) + queryString);
×
434
            req.response().setStatusCode(301).end();
×
435
        } else if (req.queryParam("for-pubkey").size() == 1) {
×
436
            String type = req.queryParam("for-pubkey").get(0);
×
437
            req.response().putHeader("location", path + "/pubkey/" + Utils.createHash(type) + queryString);
×
438
            req.response().setStatusCode(301).end();
×
439
        } else if (req.queryParam("for-user").size() == 1) {
×
440
            String type = req.queryParam("for-user").get(0);
×
441
            req.response().putHeader("location", path + "/user/" + Utils.createHash(type) + queryString);
×
442
            req.response().setStatusCode(301).end();
×
443
        }
444
    }
×
445

446
    /**
447
     * Apply headers to the response that should be present for all requests.
448
     *
449
     * @param response The response to which the headers should be applied.
450
     */
451
    private static void applyGlobalHeaders(HttpServerResponse response) {
452
        response.putHeader("Nanopub-Query-Status", StatusController.get().getState().state.toString());
×
453
    }
×
454
}
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