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

stephenafamo / bob / 12288008769

12 Dec 2024 01:21AM UTC coverage: 48.932% (-0.2%) from 49.164%
12288008769

Pull #325

github

stephenafamo
Modify expected structure of template FS
Pull Request #325: Refactor template parsing and organisation

113 of 139 new or added lines in 3 files covered. (81.29%)

6 existing lines in 3 files now uncovered.

6323 of 12922 relevant lines covered (48.93%)

223.26 hits per line

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

81.5
/gen/output.go
1
package gen
2

3
import (
4
        "bufio"
5
        "bytes"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "io/fs"
10
        "maps"
11
        "os"
12
        "path/filepath"
13
        "regexp"
14
        "slices"
15
        "strconv"
16
        "strings"
17
        "text/template"
18

19
        "github.com/stephenafamo/bob/gen/importers"
20
        "mvdan.cc/gofumpt/format"
21
)
22

23
// Copied from the go source
24
// see: https://github.com/golang/go/blob/master/src/go/build/syslist.go
25
//
26
//nolint:gochecknoglobals
27
var (
28
        goosList = stringSliceToMap(strings.Fields("aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris windows zos"))
29

30
        goarchList = stringSliceToMap(strings.Fields("386 amd64 amd64p32 arm armbe arm64 arm64be loong64 mips mipsle mips64 mips64le mips64p32 mips64p32le ppc ppc64 ppc64le riscv riscv64 s390 s390x sparc sparc64 wasm"))
31
)
32

33
//nolint:gochecknoglobals
34
var (
35
        noEditDisclaimerFmt = `// Code generated%s. DO NOT EDIT.
36
// This file is meant to be re-generated in place and/or deleted at any time.
37

38
`
39
        noEditDisclaimer = []byte(fmt.Sprintf(noEditDisclaimerFmt, " "))
40
)
41

42
//nolint:gochecknoglobals
43
var (
44
        rgxRemoveNumberedPrefix = regexp.MustCompile(`^[0-9]+_`)
45
        rgxSyntaxError          = regexp.MustCompile(`(\d+):\d+: `)
46

47
        testHarnessWriteFile = os.WriteFile
48
)
49

50
type Output struct {
51
        // The key has to be unique in a gen.State
52
        // it also makes it possible to target modifing a specific output
53
        Key string
54

55
        PkgName   string
56
        OutFolder string
57
        Templates []fs.FS
58

59
        singletonTemplates *template.Template
60
        tableTemplates     *template.Template
61

62
        // Scratch buffers used as staging area for preparing parsed template data
63
        templateByteBuffer       *bytes.Buffer
64
        templateHeaderByteBuffer *bytes.Buffer
65
}
66

67
func (o *Output) numTemplates() int {
40✔
68
        return len(o.singletonTemplates.Templates()) + len(o.tableTemplates.Templates())
40✔
69
}
40✔
70

71
// initOutFolders creates the folders that will hold the generated output.
72
func (o *Output) initOutFolders(wipe bool) error {
40✔
73
        if wipe {
40✔
74
                if err := os.RemoveAll(o.OutFolder); err != nil {
×
75
                        return fmt.Errorf("unable to wipe output folder: %w", err)
×
76
                }
×
77
        }
78

79
        files, err := os.ReadDir(o.OutFolder)
40✔
80
        if err != nil && !errors.Is(err, os.ErrNotExist) {
40✔
81
                return fmt.Errorf("unable to read output folder: %w", err)
×
82
        }
×
83

84
        for _, d := range files {
60✔
85
                if d.IsDir() {
20✔
86
                        continue
×
87
                }
88

89
                name := d.Name()
20✔
90
                name = name[:len(name)-len(filepath.Ext(name))]
20✔
91

20✔
92
                if !strings.HasSuffix(name, ".bob") && !strings.HasSuffix(name, ".bob_test") {
40✔
93
                        continue
20✔
94
                }
95

96
                if err := os.Remove(filepath.Join(o.OutFolder, d.Name())); err != nil {
×
97
                        return fmt.Errorf("unable to remove old file: %w", err)
×
98
                }
×
99
        }
100

101
        if err := os.MkdirAll(o.OutFolder, os.ModePerm); err != nil {
40✔
UNCOV
102
                return err
×
103
        }
×
104

105
        return nil
40✔
106
}
107

108
// initTemplates loads all template folders into the state object.
109
//
110
// If TemplateDirs is set it uses those, else it pulls from assets.
111
// Then it allows drivers to override, followed by replacements. Any
112
// user functions passed in by library users will be merged into the
113
// template.FuncMap.
114
//
115
// Because there's the chance for windows paths to jumped in
116
// all paths are converted to the native OS's slash style.
117
//
118
// Later, in order to properly look up imports the paths will
119
// be forced back to linux style paths.
120
func (o *Output) initTemplates(funcs template.FuncMap) error {
40✔
121
        if len(o.Templates) == 0 {
40✔
NEW
122
                return errors.New("No templates defined")
×
NEW
123
        }
×
124

125
        o.singletonTemplates = template.New("")
40✔
126
        o.tableTemplates = template.New("")
40✔
127

40✔
128
        if err := addTemplates(o.singletonTemplates, o.Templates, funcs, ".", true); err != nil {
40✔
NEW
129
                return fmt.Errorf("failed to add singleton templates: %w", err)
×
NEW
130
        }
×
131

132
        if err := addTemplates(o.tableTemplates, o.Templates, funcs, "table", false); err != nil {
40✔
NEW
133
                return fmt.Errorf("failed to add table templates: %w", err)
×
UNCOV
134
        }
×
135

136
        return nil
40✔
137
}
138

139
func addTemplates(tpl *template.Template, tempFSs []fs.FS, funcs template.FuncMap, dir string, singletons bool) error {
80✔
140
        type details struct {
80✔
141
                fs       fs.FS
80✔
142
                fullPath string
80✔
143
        }
80✔
144
        all := make(map[string]details)
80✔
145

80✔
146
        for _, tempFS := range tempFSs {
176✔
147
                if tempFS == nil {
96✔
148
                        continue
×
149
                }
150

151
                if dir != "" {
192✔
152
                        tempFS, _ = fs.Sub(tempFS, dir)
96✔
153
                        if tempFS == nil {
96✔
NEW
154
                                continue
×
155
                        }
156
                }
157

158
                entries, err := fs.ReadDir(tempFS, ".")
96✔
159
                if err != nil {
96✔
NEW
160
                        return fmt.Errorf("failed to read dir %q: %w", dir, err)
×
NEW
161
                }
×
162

163
                for _, entry := range entries {
620✔
164
                        if entry.IsDir() {
572✔
165
                                continue
48✔
166
                        }
167

168
                        name := entry.Name()
476✔
169
                        ext := filepath.Ext(name)
476✔
170
                        if ext != ".tpl" {
476✔
NEW
171
                                continue
×
172
                        }
173

174
                        if singletons {
644✔
175
                                ext2 := filepath.Ext(name[:len(name)-len(ext)])
168✔
176
                                fNameWithoutExts := filepath.Base(name[:len(name)-len(ext)-len(ext2)])
168✔
177
                                if !strings.HasSuffix(fNameWithoutExts, ".bob") &&
168✔
178
                                        !strings.HasSuffix(fNameWithoutExts, ".bob_test") {
168✔
NEW
179
                                        panic(fmt.Sprintf("singleton file name must end with .bob or .bob_test: %s", name))
×
180
                                }
181
                        }
182

183
                        all[name] = details{
476✔
184
                                fs:       tempFS,
476✔
185
                                fullPath: filepath.Join(dir, name),
476✔
186
                        }
476✔
187
                }
188
        }
189

190
        paths := slices.Collect(maps.Keys(all))
80✔
191
        slices.Sort(paths)
80✔
192

80✔
193
        for _, path := range paths {
556✔
194
                details := all[path]
476✔
195
                content, err := fs.ReadFile(details.fs, path)
476✔
196
                if err != nil {
476✔
NEW
197
                        return fmt.Errorf("failed to read template: %s: %w", details.fullPath, err)
×
NEW
198
                }
×
199

200
                err = loadTemplate(tpl, funcs, path, string(content))
476✔
201
                if err != nil {
476✔
NEW
202
                        return fmt.Errorf("failed to load template: %s: %w", details.fullPath, err)
×
203
                }
×
204
        }
205

206
        return nil
80✔
207
}
208

209
type executeTemplateData[T, C, I any] struct {
210
        output *Output
211
        data   *TemplateData[T, C, I]
212

213
        templates     *template.Template
214
        dirExtensions dirExtMap
215
}
216

217
// generateOutput builds the file output and sends it to outHandler for saving
218
func generateOutput[T, C, I any](o *Output, dirExts dirExtMap, data *TemplateData[T, C, I], goVersion string, noTests bool) error {
832✔
219
        if err := executeTemplates(executeTemplateData[T, C, I]{
832✔
220
                output:        o,
832✔
221
                data:          data,
832✔
222
                templates:     o.tableTemplates,
832✔
223
                dirExtensions: dirExts,
832✔
224
        }, goVersion, false); err != nil {
832✔
NEW
225
                return fmt.Errorf("execute templates: %w", err)
×
NEW
226
        }
×
227

228
        if noTests {
832✔
NEW
229
                return nil
×
NEW
230
        }
×
231

232
        if err := executeTemplates(executeTemplateData[T, C, I]{
832✔
233
                output:        o,
832✔
234
                data:          data,
832✔
235
                templates:     o.tableTemplates,
832✔
236
                dirExtensions: dirExts,
832✔
237
        }, goVersion, true); err != nil {
832✔
NEW
238
                return fmt.Errorf("execute test templates: %w", err)
×
NEW
239
        }
×
240

241
        return nil
832✔
242
}
243

244
// generateSingletonOutput processes the templates that should only be run
245
// one time.
246
func generateSingletonOutput[T, C, I any](o *Output, data *TemplateData[T, C, I], goVersion string, noTests bool) error {
40✔
247
        if err := executeSingletonTemplates(executeTemplateData[T, C, I]{
40✔
248
                output:    o,
40✔
249
                data:      data,
40✔
250
                templates: o.singletonTemplates,
40✔
251
        }, goVersion, false); err != nil {
40✔
NEW
252
                return fmt.Errorf("execute singleton templates: %w", err)
×
NEW
253
        }
×
254

255
        if noTests {
40✔
NEW
256
                return nil
×
NEW
257
        }
×
258

259
        if err := executeSingletonTemplates(executeTemplateData[T, C, I]{
40✔
260
                output:    o,
40✔
261
                data:      data,
40✔
262
                templates: o.singletonTemplates,
40✔
263
        }, goVersion, true); err != nil {
40✔
NEW
264
                return fmt.Errorf("execute singleton test templates: %w", err)
×
NEW
265
        }
×
266

267
        return nil
40✔
268
}
269

270
func executeTemplates[T, C, I any](e executeTemplateData[T, C, I], goVersion string, tests bool) error {
1,664✔
271
        for dir, dirExts := range e.dirExtensions {
3,328✔
272
                for ext, tplNames := range dirExts {
3,328✔
273
                        headerOut := e.output.templateHeaderByteBuffer
1,664✔
274
                        headerOut.Reset()
1,664✔
275
                        out := e.output.templateByteBuffer
1,664✔
276
                        out.Reset()
1,664✔
277

1,664✔
278
                        isGo := filepath.Ext(ext) == ".go"
1,664✔
279

1,664✔
280
                        prevLen := out.Len()
1,664✔
281
                        e.data.ResetImports()
1,664✔
282

1,664✔
283
                        matchingTemplates := 0
1,664✔
284
                        for _, tplName := range tplNames {
14,496✔
285
                                if tests != strings.Contains(tplName, "_test.go") {
19,248✔
286
                                        continue
6,416✔
287
                                }
288
                                matchingTemplates++
6,416✔
289

6,416✔
290
                                if err := executeTemplate(out, e.templates, tplName, e.data); err != nil {
6,416✔
291
                                        return err
×
292
                                }
×
293
                        }
294

295
                        if matchingTemplates == 0 {
2,080✔
296
                                continue
416✔
297
                        }
298

299
                        fName := getOutputFilename(e.data.Table.Schema, e.data.Table.Name, isGo)
1,248✔
300
                        fName += ".bob"
1,248✔
301
                        if tests {
1,664✔
302
                                fName += "_test"
416✔
303
                        }
416✔
304

305
                        fName += ext
1,248✔
306
                        if len(dir) != 0 {
1,248✔
307
                                fName = filepath.Join(dir, fName)
×
308
                        }
×
309

310
                        // Skip writing the file if the content is empty
311
                        if out.Len()-prevLen < 1 {
1,612✔
312
                                fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
364✔
313
                                        "== SKIPPED ==", e.output.OutFolder, fName)
364✔
314
                                continue
364✔
315
                        }
316

317
                        fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
884✔
318
                                fmt.Sprintf("%7d bytes", out.Len()-prevLen),
884✔
319
                                e.output.OutFolder, fName)
884✔
320

884✔
321
                        imps := e.data.Importer.ToList()
884✔
322
                        version := ""
884✔
323
                        if isGo {
1,768✔
324
                                pkgName := e.output.PkgName
884✔
325
                                if len(dir) != 0 {
884✔
326
                                        pkgName = filepath.Base(dir)
×
327
                                }
×
328
                                if tests {
936✔
329
                                        pkgName = fmt.Sprintf("%s_test", pkgName)
52✔
330
                                }
52✔
331
                                version = goVersion
884✔
332
                                writeFileDisclaimer(headerOut)
884✔
333
                                writePackageName(headerOut, pkgName)
884✔
334
                                writeImports(headerOut, imps)
884✔
335
                        }
336

337
                        if err := writeFile(e.output.OutFolder, fName, io.MultiReader(headerOut, out), version); err != nil {
884✔
338
                                return err
×
339
                        }
×
340
                }
341
        }
342

343
        return nil
1,664✔
344
}
345

346
func executeSingletonTemplates[T, C, I any](e executeTemplateData[T, C, I], goVersion string, tests bool) error {
80✔
347
        headerOut := e.output.templateHeaderByteBuffer
80✔
348
        out := e.output.templateByteBuffer
80✔
349
        for _, tpl := range e.templates.Templates() {
536✔
350
                if !strings.HasSuffix(tpl.Name(), ".tpl") {
576✔
351
                        continue
120✔
352
                }
353

354
                if tests != strings.Contains(tpl.Name(), "_test.go") {
504✔
355
                        continue
168✔
356
                }
357

358
                normalized, isGo := outputFilenameParts(tpl.Name())
168✔
359

168✔
360
                headerOut.Reset()
168✔
361
                out.Reset()
168✔
362
                prevLen := out.Len()
168✔
363

168✔
364
                e.data.ResetImports()
168✔
365
                if err := executeTemplate(out, e.templates, tpl.Name(), e.data); err != nil {
168✔
366
                        return err
×
367
                }
×
368

369
                // Skip writing the file if the content is empty
370
                if out.Len()-prevLen < 1 {
192✔
371
                        fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
24✔
372
                                "== SKIPPED ==", e.output.OutFolder, normalized)
24✔
373
                        continue
24✔
374
                }
375

376
                fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
144✔
377
                        fmt.Sprintf("%7d bytes", out.Len()-prevLen),
144✔
378
                        e.output.OutFolder, normalized)
144✔
379

144✔
380
                version := ""
144✔
381
                if isGo {
288✔
382
                        imps := e.data.Importer.ToList()
144✔
383
                        version = goVersion
144✔
384

144✔
385
                        writeFileDisclaimer(headerOut)
144✔
386
                        writePackageName(headerOut, e.output.PkgName)
144✔
387
                        writeImports(headerOut, imps)
144✔
388
                }
144✔
389

390
                if err := writeFile(e.output.OutFolder, normalized, io.MultiReader(headerOut, out), version); err != nil {
144✔
391
                        return err
×
392
                }
×
393
        }
394

395
        return nil
80✔
396
}
397

398
// writeFileDisclaimer writes the disclaimer at the top with a trailing
399
// newline so the package name doesn't get attached to it.
400
func writeFileDisclaimer(out *bytes.Buffer) {
1,028✔
401
        _, _ = out.Write(noEditDisclaimer)
1,028✔
402
}
1,028✔
403

404
// writePackageName writes the package name correctly, ignores errors
405
// since it's to the concrete buffer type which produces none
406
func writePackageName(out *bytes.Buffer, pkgName string) {
1,030✔
407
        _, _ = fmt.Fprintf(out, "package %s\n\n", pkgName)
1,030✔
408
}
1,030✔
409

410
// writeImports writes the package imports correctly, ignores errors
411
// since it's to the concrete buffer type which produces none
412
func writeImports(out *bytes.Buffer, imps importers.List) {
1,028✔
413
        if impStr := imps.Format(); len(impStr) > 0 {
2,036✔
414
                _, _ = fmt.Fprintf(out, "%s\n", impStr)
1,008✔
415
        }
1,008✔
416
}
417

418
// writeFile writes to the given folder and filename, formatting the buffer
419
// given.
420
// If goVersion is empty, the file is not formatted.
421
func writeFile(outFolder string, fileName string, input io.Reader, goVersion string) error {
1,030✔
422
        var byt []byte
1,030✔
423
        var err error
1,030✔
424
        if goVersion != "" {
2,060✔
425
                byt, err = formatBuffer(input, goVersion)
1,030✔
426
                if err != nil {
1,030✔
427
                        return err
×
428
                }
×
429
        } else {
×
430
                byt, err = io.ReadAll(input)
×
431
                if err != nil {
×
432
                        return err
×
433
                }
×
434
        }
435

436
        path := filepath.Join(outFolder, fileName)
1,030✔
437
        if err := testHarnessWriteFile(path, byt, 0o664); err != nil {
1,030✔
438
                return fmt.Errorf("failed to write output file %s: %w", path, err)
×
439
        }
×
440

441
        return nil
1,030✔
442
}
443

444
// executeTemplate takes a template and returns the output of the template
445
// execution.
446
func executeTemplate[T, C, I any](buf io.Writer, t *template.Template, name string, data *TemplateData[T, C, I]) (err error) {
6,584✔
447
        defer func() {
13,168✔
448
                if r := recover(); r != nil {
6,584✔
449
                        err = fmt.Errorf("failed to execute template: %s\npanic: %+v\n", name, r)
×
450
                }
×
451
        }()
452

453
        if err := t.ExecuteTemplate(buf, name, data); err != nil {
6,584✔
454
                return fmt.Errorf("failed to execute template: %s: %w", name, err)
×
455
        }
×
456
        return nil
6,584✔
457
}
458

459
func formatBuffer(buf io.Reader, version string) ([]byte, error) {
1,032✔
460
        src, err := io.ReadAll(buf)
1,032✔
461
        if err != nil {
1,032✔
462
                return nil, err
×
463
        }
×
464

465
        output, err := format.Source(src, format.Options{LangVersion: version})
1,032✔
466
        if err == nil {
2,062✔
467
                return output, nil
1,030✔
468
        }
1,030✔
469

470
        matches := rgxSyntaxError.FindStringSubmatch(err.Error())
2✔
471
        if matches == nil {
2✔
472
                return nil, fmt.Errorf("failed to format template: %w", err)
×
473
        }
×
474

475
        lineNum, _ := strconv.Atoi(matches[1])
2✔
476
        scanner := bufio.NewScanner(bytes.NewBuffer(src))
2✔
477
        errBuf := &bytes.Buffer{}
2✔
478
        line := 1
2✔
479
        for ; scanner.Scan(); line++ {
8✔
480
                if delta := line - lineNum; delta < -5 || delta > 5 {
6✔
481
                        continue
×
482
                }
483

484
                if line == lineNum {
8✔
485
                        errBuf.WriteString(">>>> ")
2✔
486
                } else {
6✔
487
                        fmt.Fprintf(errBuf, "% 4d ", line)
4✔
488
                }
4✔
489
                errBuf.Write(scanner.Bytes())
6✔
490
                errBuf.WriteByte('\n')
6✔
491
        }
492

493
        return nil, fmt.Errorf("failed to format template\n\n%s\n:%w", errBuf.Bytes(), err)
2✔
494
}
495

496
func getLongExt(filename string) string {
308✔
497
        index := strings.IndexByte(filename, '.')
308✔
498
        return filename[index:]
308✔
499
}
308✔
500

501
func getOutputFilename(schema, tableName string, isGo bool) string {
1,264✔
502
        output := tableName
1,264✔
503
        if strings.HasPrefix(output, "_") {
1,266✔
504
                output = "und" + output
2✔
505
        }
2✔
506

507
        if isGo && endsWithSpecialSuffix(output) {
1,272✔
508
                output += "_model"
8✔
509
        }
8✔
510

511
        if schema != "" {
1,602✔
512
                output += "." + schema
338✔
513
        }
338✔
514

515
        return output
1,264✔
516
}
517

518
// See: https://pkg.go.dev/cmd/go#hdr-Build_constraints
519
func endsWithSpecialSuffix(tableName string) bool {
1,262✔
520
        parts := strings.Split(tableName, "_")
1,262✔
521

1,262✔
522
        // Not enough parts to have a special suffix
1,262✔
523
        if len(parts) < 2 {
1,698✔
524
                return false
436✔
525
        }
436✔
526

527
        lastPart := parts[len(parts)-1]
826✔
528

826✔
529
        if lastPart == "test" {
828✔
530
                return true
2✔
531
        }
2✔
532

533
        if _, ok := goosList[lastPart]; ok {
828✔
534
                return true
4✔
535
        }
4✔
536

537
        if _, ok := goarchList[lastPart]; ok {
822✔
538
                return true
2✔
539
        }
2✔
540

541
        return false
818✔
542
}
543

544
func stringSliceToMap(slice []string) map[string]struct{} {
24✔
545
        Map := make(map[string]struct{}, len(slice))
24✔
546
        for _, v := range slice {
516✔
547
                Map[v] = struct{}{}
492✔
548
        }
492✔
549

550
        return Map
24✔
551
}
552

553
// fileFragments will take something of the form:
554
// templates/singleton/hello.go.tpl
555
// templates_test/js/hello.js.tpl
556
//
557
//nolint:nonamedreturns
558
func outputFilenameParts(filename string) (normalized string, isGo bool) {
482✔
559
        fragments := strings.Split(filename, string(os.PathSeparator))
482✔
560

482✔
561
        newFilename := fragments[len(fragments)-1]
482✔
562
        newFilename = strings.TrimSuffix(newFilename, ".tpl")
482✔
563
        newFilename = rgxRemoveNumberedPrefix.ReplaceAllString(newFilename, "")
482✔
564
        ext := filepath.Ext(newFilename)
482✔
565
        isGo = ext == ".go"
482✔
566

482✔
567
        fragments[len(fragments)-1] = newFilename
482✔
568
        normalized = strings.Join(fragments, string(os.PathSeparator))
482✔
569

482✔
570
        return normalized, isGo
482✔
571
}
482✔
572

573
type dirExtMap map[string]map[string][]string
574

575
// groupTemplates takes templates and groups them according to their output directory
576
// and file extension.
577
func groupTemplates(templates *template.Template) dirExtMap {
40✔
578
        tplNames := templates.Templates()
40✔
579
        dirs := make(map[string]map[string][]string)
40✔
580
        for _, tpl := range tplNames {
508✔
581
                if !strings.HasSuffix(tpl.Name(), ".tpl") {
628✔
582
                        continue
160✔
583
                }
584

585
                normalized, _ := outputFilenameParts(tpl.Name())
308✔
586
                dir := filepath.Dir(normalized)
308✔
587
                if dir == "." {
616✔
588
                        dir = ""
308✔
589
                }
308✔
590

591
                extensions, ok := dirs[dir]
308✔
592
                if !ok {
348✔
593
                        extensions = make(map[string][]string)
40✔
594
                        dirs[dir] = extensions
40✔
595
                }
40✔
596

597
                ext := getLongExt(tpl.Name())
308✔
598
                ext = strings.TrimSuffix(ext, ".tpl")
308✔
599
                slice := extensions[ext]
308✔
600
                extensions[ext] = append(slice, tpl.Name())
308✔
601
        }
602

603
        for _, exts := range dirs {
80✔
604
                for _, tplNames := range exts {
80✔
605
                        slices.Sort(tplNames)
40✔
606
                }
40✔
607
        }
608

609
        return dirs
40✔
610
}
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