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

geo-engine / geoengine / 22727912792

05 Mar 2026 04:53PM UTC coverage: 88.136%. First build
22727912792

Pull #1116

github

web-flow
Merge 2e8280d6e into 6d70b3157
Pull Request #1116: feat: Operators in OpenAPI

270 of 385 new or added lines in 4 files covered. (70.13%)

112650 of 127814 relevant lines covered (88.14%)

506691.06 hits per line

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

50.89
/services/src/api/model/processing_graphs/parameters.rs
1
use crate::api::model::processing_graphs::{RasterOperator, VectorOperator};
2
use anyhow::Context;
3
use geoengine_macros::type_tag;
4
use serde::{Deserialize, Serialize, Serializer};
5
use std::collections::BTreeMap;
6
use utoipa::ToSchema;
7

8
/// A 2D coordinate with `x` and `y` values.
9
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, PartialOrd, Serialize, Default, ToSchema)]
10
#[serde(rename_all = "camelCase")]
11
pub struct Coordinate2D {
12
    pub x: f64,
13
    pub y: f64,
14
}
15
impl From<Coordinate2D> for geoengine_datatypes::primitives::Coordinate2D {
16
    fn from(value: Coordinate2D) -> Self {
7✔
17
        geoengine_datatypes::primitives::Coordinate2D {
7✔
18
            x: value.x,
7✔
19
            y: value.y,
7✔
20
        }
7✔
21
    }
7✔
22
}
23

24
impl From<geoengine_datatypes::primitives::Coordinate2D> for Coordinate2D {
25
    fn from(value: geoengine_datatypes::primitives::Coordinate2D) -> Self {
1✔
26
        Coordinate2D {
1✔
27
            x: value.x,
1✔
28
            y: value.y,
1✔
29
        }
1✔
30
    }
1✔
31
}
32

33
/// A raster data type.
34
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
35
#[serde(rename_all = "camelCase")]
36
pub enum RasterDataType {
37
    U8,
38
    U16,
39
    U32,
40
    U64,
41
    I8,
42
    I16,
43
    I32,
44
    I64,
45
    F32,
46
    F64,
47
}
48

49
impl From<geoengine_datatypes::raster::RasterDataType> for RasterDataType {
50
    fn from(value: geoengine_datatypes::raster::RasterDataType) -> Self {
1✔
51
        match value {
1✔
NEW
52
            geoengine_datatypes::raster::RasterDataType::U8 => Self::U8,
×
NEW
53
            geoengine_datatypes::raster::RasterDataType::U16 => Self::U16,
×
NEW
54
            geoengine_datatypes::raster::RasterDataType::U32 => Self::U32,
×
NEW
55
            geoengine_datatypes::raster::RasterDataType::U64 => Self::U64,
×
NEW
56
            geoengine_datatypes::raster::RasterDataType::I8 => Self::I8,
×
NEW
57
            geoengine_datatypes::raster::RasterDataType::I16 => Self::I16,
×
NEW
58
            geoengine_datatypes::raster::RasterDataType::I32 => Self::I32,
×
NEW
59
            geoengine_datatypes::raster::RasterDataType::I64 => Self::I64,
×
60
            geoengine_datatypes::raster::RasterDataType::F32 => Self::F32,
1✔
NEW
61
            geoengine_datatypes::raster::RasterDataType::F64 => Self::F64,
×
62
        }
63
    }
1✔
64
}
65

66
impl From<RasterDataType> for geoengine_datatypes::raster::RasterDataType {
67
    fn from(value: RasterDataType) -> Self {
2✔
68
        match value {
2✔
NEW
69
            RasterDataType::U8 => Self::U8,
×
NEW
70
            RasterDataType::U16 => Self::U16,
×
NEW
71
            RasterDataType::U32 => Self::U32,
×
NEW
72
            RasterDataType::U64 => Self::U64,
×
NEW
73
            RasterDataType::I8 => Self::I8,
×
NEW
74
            RasterDataType::I16 => Self::I16,
×
NEW
75
            RasterDataType::I32 => Self::I32,
×
NEW
76
            RasterDataType::I64 => Self::I64,
×
77
            RasterDataType::F32 => Self::F32,
2✔
NEW
78
            RasterDataType::F64 => Self::F64,
×
79
        }
80
    }
2✔
81
}
82

83
/// Measurement information for a raster band.
84
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
85
#[serde(rename_all = "camelCase", untagged)]
86
#[schema(discriminator = "type")]
87
pub enum Measurement {
88
    Unitless(UnitlessMeasurement),
89
    Continuous(ContinuousMeasurement),
90
    Classification(ClassificationMeasurement),
91
}
92

93
impl From<geoengine_datatypes::primitives::Measurement> for Measurement {
94
    fn from(value: geoengine_datatypes::primitives::Measurement) -> Self {
1✔
95
        match value {
1✔
96
            geoengine_datatypes::primitives::Measurement::Unitless => {
97
                Self::Unitless(UnitlessMeasurement {
1✔
98
                    r#type: Default::default(),
1✔
99
                })
1✔
100
            }
NEW
101
            geoengine_datatypes::primitives::Measurement::Continuous(cm) => {
×
NEW
102
                Self::Continuous(cm.into())
×
103
            }
NEW
104
            geoengine_datatypes::primitives::Measurement::Classification(cm) => {
×
NEW
105
                Self::Classification(cm.into())
×
106
            }
107
        }
108
    }
1✔
109
}
110

111
impl From<Measurement> for geoengine_datatypes::primitives::Measurement {
112
    fn from(value: Measurement) -> Self {
1✔
113
        match value {
1✔
114
            Measurement::Unitless(_) => Self::Unitless,
1✔
NEW
115
            Measurement::Continuous(cm) => Self::Continuous(cm.into()),
×
NEW
116
            Measurement::Classification(cm) => Self::Classification(cm.into()),
×
117
        }
118
    }
1✔
119
}
120

121
/// A measurement without a unit.
122
#[type_tag(value = "unitless")]
123
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema, Default)]
124
pub struct UnitlessMeasurement {}
125

126
/// A continuous measurement, e.g., "temperature".
127
/// It may have an optional unit, e.g., "°C" for degrees Celsius.
128
#[type_tag(value = "continuous")]
129
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
130
pub struct ContinuousMeasurement {
131
    pub measurement: String,
132
    pub unit: Option<String>,
133
}
134

135
impl From<geoengine_datatypes::primitives::ContinuousMeasurement> for ContinuousMeasurement {
NEW
136
    fn from(value: geoengine_datatypes::primitives::ContinuousMeasurement) -> Self {
×
NEW
137
        ContinuousMeasurement {
×
NEW
138
            r#type: Default::default(),
×
NEW
139
            measurement: value.measurement,
×
NEW
140
            unit: value.unit,
×
NEW
141
        }
×
NEW
142
    }
×
143
}
144

145
impl From<ContinuousMeasurement> for geoengine_datatypes::primitives::ContinuousMeasurement {
NEW
146
    fn from(value: ContinuousMeasurement) -> Self {
×
NEW
147
        Self {
×
NEW
148
            measurement: value.measurement,
×
NEW
149
            unit: value.unit,
×
NEW
150
        }
×
NEW
151
    }
×
152
}
153

154
impl From<geoengine_datatypes::primitives::ClassificationMeasurement>
155
    for ClassificationMeasurement
156
{
NEW
157
    fn from(value: geoengine_datatypes::primitives::ClassificationMeasurement) -> Self {
×
NEW
158
        let mut classes = BTreeMap::new();
×
NEW
159
        for (k, v) in value.classes {
×
NEW
160
            classes.insert(k, v);
×
NEW
161
        }
×
NEW
162
        ClassificationMeasurement {
×
NEW
163
            r#type: Default::default(),
×
NEW
164
            measurement: value.measurement,
×
NEW
165
            classes,
×
NEW
166
        }
×
NEW
167
    }
×
168
}
169

170
impl From<ClassificationMeasurement>
171
    for geoengine_datatypes::primitives::ClassificationMeasurement
172
{
NEW
173
    fn from(value: ClassificationMeasurement) -> Self {
×
NEW
174
        geoengine_datatypes::primitives::ClassificationMeasurement {
×
NEW
175
            measurement: value.measurement,
×
NEW
176
            classes: value.classes,
×
NEW
177
        }
×
NEW
178
    }
×
179
}
180

181
/// A classification measurement.
182
/// It contains a mapping from class IDs to class names.
183
#[type_tag(value = "classification")]
184
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
185
pub struct ClassificationMeasurement {
186
    pub measurement: String,
187
    #[serde(serialize_with = "serialize_classes")]
188
    #[serde(deserialize_with = "deserialize_classes")]
189
    pub classes: BTreeMap<u8, String>,
190
}
191

NEW
192
fn serialize_classes<S>(classes: &BTreeMap<u8, String>, serializer: S) -> Result<S::Ok, S::Error>
×
NEW
193
where
×
NEW
194
    S: Serializer,
×
195
{
196
    use serde::ser::SerializeMap;
197

NEW
198
    let mut map = serializer.serialize_map(Some(classes.len()))?;
×
NEW
199
    for (k, v) in classes {
×
NEW
200
        map.serialize_entry(&k.to_string(), v)?;
×
201
    }
NEW
202
    map.end()
×
NEW
203
}
×
204

NEW
205
fn deserialize_classes<'de, D>(deserializer: D) -> Result<BTreeMap<u8, String>, D::Error>
×
NEW
206
where
×
NEW
207
    D: serde::de::Deserializer<'de>,
×
208
{
209
    use serde::de::{MapAccess, Visitor};
210
    use std::fmt;
211

212
    struct ClassesVisitor;
213

214
    impl<'de> Visitor<'de> for ClassesVisitor {
215
        type Value = BTreeMap<u8, String>;
216

NEW
217
        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
×
NEW
218
            formatter.write_str("a map with numeric string keys")
×
NEW
219
        }
×
220

NEW
221
        fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
×
NEW
222
        where
×
NEW
223
            M: MapAccess<'de>,
×
224
        {
NEW
225
            let mut map = BTreeMap::new();
×
NEW
226
            while let Some((key, value)) = access.next_entry::<String, String>()? {
×
NEW
227
                let k = key.parse::<u8>().map_err(serde::de::Error::custom)?;
×
NEW
228
                map.insert(k, value);
×
229
            }
NEW
230
            Ok(map)
×
NEW
231
        }
×
232
    }
233

NEW
234
    deserializer.deserialize_map(ClassesVisitor)
×
NEW
235
}
×
236

237
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, ToSchema)]
238
#[serde(rename_all = "camelCase")]
239
pub struct RasterBandDescriptor {
240
    pub name: String,
241
    pub measurement: Measurement,
242
}
243

244
impl From<geoengine_operators::engine::RasterBandDescriptor> for RasterBandDescriptor {
245
    fn from(value: geoengine_operators::engine::RasterBandDescriptor) -> Self {
1✔
246
        Self {
1✔
247
            name: value.name,
1✔
248
            measurement: value.measurement.into(),
1✔
249
        }
1✔
250
    }
1✔
251
}
252

253
impl From<RasterBandDescriptor> for geoengine_operators::engine::RasterBandDescriptor {
254
    fn from(value: RasterBandDescriptor) -> Self {
1✔
255
        Self {
1✔
256
            name: value.name,
1✔
257
            measurement: value.measurement.into(),
1✔
258
        }
1✔
259
    }
1✔
260
}
261

262
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
263
#[serde(rename_all = "camelCase", tag = "type")]
264
pub enum ColumnNames {
265
    #[schema(title = "Default")]
266
    Default,
267
    #[schema(title = "Suffix")]
268
    Suffix { values: Vec<String> },
269
    #[schema(title = "Names")]
270
    Names { values: Vec<String> },
271
}
272

273
impl From<geoengine_operators::processing::ColumnNames> for ColumnNames {
NEW
274
    fn from(value: geoengine_operators::processing::ColumnNames) -> Self {
×
NEW
275
        match value {
×
NEW
276
            geoengine_operators::processing::ColumnNames::Default => ColumnNames::Default,
×
NEW
277
            geoengine_operators::processing::ColumnNames::Suffix(v) => {
×
NEW
278
                ColumnNames::Suffix { values: v }
×
279
            }
NEW
280
            geoengine_operators::processing::ColumnNames::Names(v) => {
×
NEW
281
                ColumnNames::Names { values: v }
×
282
            }
283
        }
NEW
284
    }
×
285
}
286

287
impl From<ColumnNames> for geoengine_operators::processing::ColumnNames {
288
    fn from(value: ColumnNames) -> Self {
1✔
289
        match value {
1✔
NEW
290
            ColumnNames::Default => geoengine_operators::processing::ColumnNames::Default,
×
NEW
291
            ColumnNames::Suffix { values } => {
×
NEW
292
                geoengine_operators::processing::ColumnNames::Suffix(values)
×
293
            }
294
            ColumnNames::Names { values } => {
1✔
295
                geoengine_operators::processing::ColumnNames::Names(values)
1✔
296
            }
297
        }
298
    }
1✔
299
}
300

301
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
302
#[serde(rename_all = "camelCase")]
303
pub enum FeatureAggregationMethod {
304
    First,
305
    Mean,
306
}
307

308
impl From<geoengine_operators::processing::FeatureAggregationMethod> for FeatureAggregationMethod {
NEW
309
    fn from(value: geoengine_operators::processing::FeatureAggregationMethod) -> Self {
×
NEW
310
        match value {
×
311
            geoengine_operators::processing::FeatureAggregationMethod::First => {
NEW
312
                FeatureAggregationMethod::First
×
313
            }
314
            geoengine_operators::processing::FeatureAggregationMethod::Mean => {
NEW
315
                FeatureAggregationMethod::Mean
×
316
            }
317
        }
NEW
318
    }
×
319
}
320

321
impl From<FeatureAggregationMethod> for geoengine_operators::processing::FeatureAggregationMethod {
322
    fn from(value: FeatureAggregationMethod) -> Self {
1✔
323
        match value {
1✔
324
            FeatureAggregationMethod::First => {
325
                geoengine_operators::processing::FeatureAggregationMethod::First
1✔
326
            }
327
            FeatureAggregationMethod::Mean => {
NEW
328
                geoengine_operators::processing::FeatureAggregationMethod::Mean
×
329
            }
330
        }
331
    }
1✔
332
}
333

334
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)]
335
#[serde(rename_all = "camelCase")]
336
pub enum TemporalAggregationMethod {
337
    None,
338
    First,
339
    Mean,
340
}
341

342
impl From<geoengine_operators::processing::TemporalAggregationMethod>
343
    for TemporalAggregationMethod
344
{
NEW
345
    fn from(value: geoengine_operators::processing::TemporalAggregationMethod) -> Self {
×
NEW
346
        match value {
×
347
            geoengine_operators::processing::TemporalAggregationMethod::None => {
NEW
348
                TemporalAggregationMethod::None
×
349
            }
350
            geoengine_operators::processing::TemporalAggregationMethod::First => {
NEW
351
                TemporalAggregationMethod::First
×
352
            }
353
            geoengine_operators::processing::TemporalAggregationMethod::Mean => {
NEW
354
                TemporalAggregationMethod::Mean
×
355
            }
356
        }
NEW
357
    }
×
358
}
359

360
impl From<TemporalAggregationMethod>
361
    for geoengine_operators::processing::TemporalAggregationMethod
362
{
363
    fn from(value: TemporalAggregationMethod) -> Self {
1✔
364
        match value {
1✔
365
            TemporalAggregationMethod::None => {
NEW
366
                geoengine_operators::processing::TemporalAggregationMethod::None
×
367
            }
368
            TemporalAggregationMethod::First => {
NEW
369
                geoengine_operators::processing::TemporalAggregationMethod::First
×
370
            }
371
            TemporalAggregationMethod::Mean => {
372
                geoengine_operators::processing::TemporalAggregationMethod::Mean
1✔
373
            }
374
        }
375
    }
1✔
376
}
377

378
/// Spatial bounds derivation options for the [`MockPointSource`].
379
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
380
#[serde(rename_all = "camelCase", untagged)]
381
#[schema(discriminator = "type")]
382
pub enum SpatialBoundsDerive {
383
    Derive(SpatialBoundsDeriveDerive),
384
    Bounds(SpatialBoundsDeriveBounds),
385
    None(SpatialBoundsDeriveNone),
386
}
387

388
impl Default for SpatialBoundsDerive {
NEW
389
    fn default() -> Self {
×
NEW
390
        SpatialBoundsDerive::None(SpatialBoundsDeriveNone::default())
×
NEW
391
    }
×
392
}
393

394
#[type_tag(value = "derive")]
395
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)]
396
pub struct SpatialBoundsDeriveDerive {}
397

398
#[type_tag(value = "bounds")]
399
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
400
pub struct SpatialBoundsDeriveBounds {
401
    #[serde(flatten)]
402
    pub bounding_box: BoundingBox2D,
403
}
404

405
#[type_tag(value = "none")]
406
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema, Default)]
407
pub struct SpatialBoundsDeriveNone {}
408

409
impl TryFrom<SpatialBoundsDerive> for geoengine_operators::mock::SpatialBoundsDerive {
410
    type Error = anyhow::Error;
411
    fn try_from(value: SpatialBoundsDerive) -> Result<Self, Self::Error> {
3✔
412
        Ok(match value {
3✔
413
            SpatialBoundsDerive::Derive(_) => {
414
                geoengine_operators::mock::SpatialBoundsDerive::Derive
3✔
415
            }
NEW
416
            SpatialBoundsDerive::Bounds(bounds) => {
×
417
                geoengine_operators::mock::SpatialBoundsDerive::Bounds(
NEW
418
                    bounds.bounding_box.try_into()?,
×
419
                )
420
            }
NEW
421
            SpatialBoundsDerive::None(_) => geoengine_operators::mock::SpatialBoundsDerive::None,
×
422
        })
423
    }
3✔
424
}
425

426
/// A bounding box that includes all border points.
427
/// Note: may degenerate to a point!
428
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
429
#[serde(rename_all = "camelCase")]
430
pub struct BoundingBox2D {
431
    lower_left_coordinate: Coordinate2D,
432
    upper_right_coordinate: Coordinate2D,
433
}
434

435
impl TryFrom<BoundingBox2D> for geoengine_datatypes::primitives::BoundingBox2D {
436
    type Error = anyhow::Error;
437
    fn try_from(value: BoundingBox2D) -> Result<Self, Self::Error> {
1✔
438
        geoengine_datatypes::primitives::BoundingBox2D::new(
1✔
439
            value.lower_left_coordinate.into(),
1✔
440
            value.upper_right_coordinate.into(),
1✔
441
        )
442
        .context("invalid bounding box")
1✔
443
    }
1✔
444
}
445

446
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
447
#[schema(no_recursion)]
448
#[serde(rename_all = "camelCase")]
449
pub struct SingleRasterSource {
450
    pub raster: RasterOperator,
451
}
452

453
impl TryFrom<SingleRasterSource> for geoengine_operators::engine::SingleRasterSource {
454
    type Error = anyhow::Error;
455

456
    fn try_from(value: SingleRasterSource) -> Result<Self, Self::Error> {
1✔
457
        Ok(Self {
458
            raster: value.raster.try_into()?,
1✔
459
        })
460
    }
1✔
461
}
462

463
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
464
#[schema(no_recursion)]
465
#[serde(rename_all = "camelCase")]
466
pub struct SingleVectorMultipleRasterSources {
467
    pub vector: VectorOperator,
468
    pub rasters: Vec<RasterOperator>,
469
}
470

471
impl TryFrom<SingleVectorMultipleRasterSources>
472
    for geoengine_operators::engine::SingleVectorMultipleRasterSources
473
{
474
    type Error = anyhow::Error;
475

476
    fn try_from(value: SingleVectorMultipleRasterSources) -> Result<Self, Self::Error> {
1✔
477
        Ok(Self {
478
            vector: value.vector.try_into()?,
1✔
479
            rasters: value
1✔
480
                .rasters
1✔
481
                .into_iter()
1✔
482
                .map(std::convert::TryInto::try_into)
1✔
483
                .collect::<Result<_, _>>()?,
1✔
484
        })
485
    }
1✔
486
}
487

488
#[cfg(test)]
489
mod tests {
490
    #![allow(clippy::float_cmp)] // ok for tests
491

492
    use geoengine_datatypes::primitives::AxisAlignedRectangle;
493

494
    use super::*;
495

496
    #[test]
497
    fn it_converts_coordinates() {
1✔
498
        let dt = geoengine_datatypes::primitives::Coordinate2D { x: 1.5, y: -2.25 };
1✔
499

500
        let api: Coordinate2D = dt.into();
1✔
501
        assert_eq!(api.x, 1.5);
1✔
502
        assert_eq!(api.y, -2.25);
1✔
503

504
        let back: geoengine_datatypes::primitives::Coordinate2D = api.into();
1✔
505
        assert_eq!(back.x, 1.5);
1✔
506
        assert_eq!(back.y, -2.25);
1✔
507
    }
1✔
508

509
    #[test]
510
    fn it_converts_raster_data_types() {
1✔
511
        use geoengine_datatypes::raster::RasterDataType as Dt;
512

513
        let dt = Dt::F32;
1✔
514
        let api: RasterDataType = dt.into();
1✔
515
        assert_eq!(api, RasterDataType::F32);
1✔
516

517
        let back: geoengine_datatypes::raster::RasterDataType = api.into();
1✔
518
        assert_eq!(back, Dt::F32);
1✔
519
    }
1✔
520

521
    #[test]
522
    fn it_converts_raster_band_descriptors() {
1✔
523
        use geoengine_datatypes::primitives::Measurement;
524
        use geoengine_operators::engine::RasterBandDescriptor as OpsDesc;
525

526
        let ops = OpsDesc {
1✔
527
            name: "band 0".into(),
1✔
528
            measurement: Measurement::Unitless,
1✔
529
        };
1✔
530

531
        let api: RasterBandDescriptor = ops.clone().into();
1✔
532
        assert_eq!(api.name, "band 0");
1✔
533

534
        let back: geoengine_operators::engine::RasterBandDescriptor = api.into();
1✔
535
        assert_eq!(back, ops);
1✔
536
    }
1✔
537

538
    #[test]
539
    fn it_converts_bounding_boxes() {
1✔
540
        let api_bbox = BoundingBox2D {
1✔
541
            lower_left_coordinate: Coordinate2D { x: 1.0, y: 2.0 },
1✔
542
            upper_right_coordinate: Coordinate2D { x: 3.0, y: 4.0 },
1✔
543
        };
1✔
544

545
        let dt_bbox: geoengine_datatypes::primitives::BoundingBox2D =
1✔
546
            api_bbox.try_into().expect("it should convert");
1✔
547

548
        assert_eq!(
1✔
549
            dt_bbox.upper_left(),
1✔
550
            geoengine_datatypes::primitives::Coordinate2D { x: 1.0, y: 4.0 }
551
        );
552
        assert_eq!(
1✔
553
            dt_bbox.lower_right(),
1✔
554
            geoengine_datatypes::primitives::Coordinate2D { x: 3.0, y: 2.0 }
555
        );
556
    }
1✔
557
}
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