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

stephenafamo / bob / 12287437408

12 Dec 2024 12:31AM UTC coverage: 48.974% (-0.2%) from 49.164%
12287437408

Pull #325

github

stephenafamo
Simplify template parsing and generation
Pull Request #325: Refactor template parsing and organisation

81 of 93 new or added lines in 3 files covered. (87.1%)

7 existing lines in 3 files now uncovered.

6326 of 12917 relevant lines covered (48.97%)

223.85 hits per line

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

84.17
/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, "singleton"); err != nil {
40✔
NEW
129
                return fmt.Errorf("failed to add singleton templates: %w", err)
×
UNCOV
130
        }
×
131

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

136
        return nil
40✔
137
}
138

139
func addTemplates(tpl *template.Template, tempFSs []fs.FS, funcs template.FuncMap, dir string) error {
80✔
140
        all := make(map[string]fs.FS)
80✔
141
        for _, tempFS := range tempFSs {
176✔
142
                if tempFS == nil {
96✔
143
                        continue
×
144
                }
145

146
                entries, err := fs.ReadDir(tempFS, dir)
96✔
147
                if err != nil {
96✔
NEW
148
                        return fmt.Errorf("failed to read dir %q: %w", dir, err)
×
NEW
149
                }
×
150

151
                for _, entry := range entries {
620✔
152
                        if entry.IsDir() {
572✔
153
                                continue
48✔
154
                        }
155

156
                        path := entry.Name()
476✔
157
                        if filepath.Ext(path) != ".tpl" {
476✔
NEW
158
                                continue
×
159
                        }
160

161
                        all[normalizeSlashes(filepath.Join(dir, path))] = tempFS
476✔
162
                }
163
        }
164

165
        paths := slices.Collect(maps.Keys(all))
80✔
166
        slices.Sort(paths)
80✔
167

80✔
168
        for _, path := range paths {
556✔
169
                content, err := fs.ReadFile(all[path], path)
476✔
170
                if err != nil {
476✔
NEW
171
                        return fmt.Errorf("failed to read template: %s: %w", path, err)
×
NEW
172
                }
×
173

174
                err = loadTemplate(tpl, funcs, path, string(content))
476✔
175
                if err != nil {
476✔
NEW
176
                        return fmt.Errorf("failed to load template: %s: %w", path, err)
×
177
                }
×
178
        }
179

180
        return nil
80✔
181
}
182

183
type executeTemplateData[T, C, I any] struct {
184
        output *Output
185
        data   *TemplateData[T, C, I]
186

187
        templates     *template.Template
188
        dirExtensions dirExtMap
189
}
190

191
// generateOutput builds the file output and sends it to outHandler for saving
192
func generateOutput[T, C, I any](o *Output, dirExts dirExtMap, data *TemplateData[T, C, I], goVersion string) error {
832✔
193
        return executeTemplates(executeTemplateData[T, C, I]{
832✔
194
                output:        o,
832✔
195
                data:          data,
832✔
196
                templates:     o.tableTemplates,
832✔
197
                dirExtensions: dirExts,
832✔
198
        }, goVersion, false)
832✔
199
}
832✔
200

201
// generateTestOutput builds the test file output and sends it to outHandler for saving
202
func generateTestOutput[T, C, I any](o *Output, dirExts dirExtMap, data *TemplateData[T, C, I], goVersion string) error {
832✔
203
        return executeTemplates(executeTemplateData[T, C, I]{
832✔
204
                output:        o,
832✔
205
                data:          data,
832✔
206
                templates:     o.tableTemplates,
832✔
207
                dirExtensions: dirExts,
832✔
208
        }, goVersion, true)
832✔
209
}
832✔
210

211
// generateSingletonOutput processes the templates that should only be run
212
// one time.
213
func generateSingletonOutput[T, C, I any](o *Output, data *TemplateData[T, C, I], goVersion string) error {
40✔
214
        return executeSingletonTemplates(executeTemplateData[T, C, I]{
40✔
215
                output:    o,
40✔
216
                data:      data,
40✔
217
                templates: o.singletonTemplates,
40✔
218
        }, goVersion, false)
40✔
219
}
40✔
220

221
// generateSingletonTestOutput processes the templates that should only be run
222
// one time.
223
func generateSingletonTestOutput[T, C, I any](o *Output, data *TemplateData[T, C, I], goVersion string) error {
40✔
224
        return executeSingletonTemplates(executeTemplateData[T, C, I]{
40✔
225
                output:    o,
40✔
226
                data:      data,
40✔
227
                templates: o.singletonTemplates,
40✔
228
        }, goVersion, true)
40✔
229
}
40✔
230

231
func executeTemplates[T, C, I any](e executeTemplateData[T, C, I], goVersion string, tests bool) error {
1,664✔
232
        for dir, dirExts := range e.dirExtensions {
3,328✔
233
                for ext, tplNames := range dirExts {
3,328✔
234
                        headerOut := e.output.templateHeaderByteBuffer
1,664✔
235
                        headerOut.Reset()
1,664✔
236
                        out := e.output.templateByteBuffer
1,664✔
237
                        out.Reset()
1,664✔
238

1,664✔
239
                        isGo := filepath.Ext(ext) == ".go"
1,664✔
240

1,664✔
241
                        prevLen := out.Len()
1,664✔
242
                        e.data.ResetImports()
1,664✔
243

1,664✔
244
                        matchingTemplates := 0
1,664✔
245
                        for _, tplName := range tplNames {
14,496✔
246
                                if tests != strings.Contains(tplName, "_test.go") {
19,248✔
247
                                        continue
6,416✔
248
                                }
249
                                matchingTemplates++
6,416✔
250

6,416✔
251
                                if err := executeTemplate(out, e.templates, tplName, e.data); err != nil {
6,416✔
252
                                        return err
×
253
                                }
×
254
                        }
255

256
                        if matchingTemplates == 0 {
2,080✔
257
                                continue
416✔
258
                        }
259

260
                        fName := getOutputFilename(e.data.Table.Schema, e.data.Table.Name, isGo)
1,248✔
261
                        fName += ".bob"
1,248✔
262
                        if tests {
1,664✔
263
                                fName += "_test"
416✔
264
                        }
416✔
265

266
                        fName += ext
1,248✔
267
                        if len(dir) != 0 {
1,248✔
268
                                fName = filepath.Join(dir, fName)
×
269
                        }
×
270

271
                        // Skip writing the file if the content is empty
272
                        if out.Len()-prevLen < 1 {
1,612✔
273
                                fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
364✔
274
                                        "== SKIPPED ==", e.output.OutFolder, fName)
364✔
275
                                continue
364✔
276
                        }
277

278
                        fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
884✔
279
                                fmt.Sprintf("%7d bytes", out.Len()-prevLen),
884✔
280
                                e.output.OutFolder, fName)
884✔
281

884✔
282
                        imps := e.data.Importer.ToList()
884✔
283
                        version := ""
884✔
284
                        if isGo {
1,768✔
285
                                pkgName := e.output.PkgName
884✔
286
                                if len(dir) != 0 {
884✔
287
                                        pkgName = filepath.Base(dir)
×
288
                                }
×
289
                                if tests {
936✔
290
                                        pkgName = fmt.Sprintf("%s_test", pkgName)
52✔
291
                                }
52✔
292
                                version = goVersion
884✔
293
                                writeFileDisclaimer(headerOut)
884✔
294
                                writePackageName(headerOut, pkgName)
884✔
295
                                writeImports(headerOut, imps)
884✔
296
                        }
297

298
                        if err := writeFile(e.output.OutFolder, fName, io.MultiReader(headerOut, out), version); err != nil {
884✔
299
                                return err
×
300
                        }
×
301
                }
302
        }
303

304
        return nil
1,664✔
305
}
306

307
func executeSingletonTemplates[T, C, I any](e executeTemplateData[T, C, I], goVersion string, tests bool) error {
80✔
308
        headerOut := e.output.templateHeaderByteBuffer
80✔
309
        out := e.output.templateByteBuffer
80✔
310
        for _, tpl := range e.templates.Templates() {
536✔
311
                if !strings.HasSuffix(tpl.Name(), ".tpl") {
576✔
312
                        continue
120✔
313
                }
314

315
                if tests != strings.Contains(tpl.Name(), "_test.go") {
504✔
316
                        continue
168✔
317
                }
318

319
                normalized, _, isGo := outputFilenameParts(tpl.Name())
168✔
320

168✔
321
                headerOut.Reset()
168✔
322
                out.Reset()
168✔
323
                prevLen := out.Len()
168✔
324

168✔
325
                e.data.ResetImports()
168✔
326
                if err := executeTemplate(out, e.templates, tpl.Name(), e.data); err != nil {
168✔
327
                        return err
×
328
                }
×
329

330
                // Skip writing the file if the content is empty
331
                if out.Len()-prevLen < 1 {
192✔
332
                        fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
24✔
333
                                "== SKIPPED ==", e.output.OutFolder, normalized)
24✔
334
                        continue
24✔
335
                }
336

337
                fmt.Fprintf(os.Stderr, "%-20s %s/%s\n",
144✔
338
                        fmt.Sprintf("%7d bytes", out.Len()-prevLen),
144✔
339
                        e.output.OutFolder, normalized)
144✔
340

144✔
341
                version := ""
144✔
342
                if isGo {
288✔
343
                        imps := e.data.Importer.ToList()
144✔
344
                        version = goVersion
144✔
345

144✔
346
                        writeFileDisclaimer(headerOut)
144✔
347
                        writePackageName(headerOut, e.output.PkgName)
144✔
348
                        writeImports(headerOut, imps)
144✔
349
                }
144✔
350

351
                if err := writeFile(e.output.OutFolder, normalized, io.MultiReader(headerOut, out), version); err != nil {
144✔
352
                        return err
×
353
                }
×
354
        }
355

356
        return nil
80✔
357
}
358

359
// writeFileDisclaimer writes the disclaimer at the top with a trailing
360
// newline so the package name doesn't get attached to it.
361
func writeFileDisclaimer(out *bytes.Buffer) {
1,028✔
362
        _, _ = out.Write(noEditDisclaimer)
1,028✔
363
}
1,028✔
364

365
// writePackageName writes the package name correctly, ignores errors
366
// since it's to the concrete buffer type which produces none
367
func writePackageName(out *bytes.Buffer, pkgName string) {
1,030✔
368
        _, _ = fmt.Fprintf(out, "package %s\n\n", pkgName)
1,030✔
369
}
1,030✔
370

371
// writeImports writes the package imports correctly, ignores errors
372
// since it's to the concrete buffer type which produces none
373
func writeImports(out *bytes.Buffer, imps importers.List) {
1,028✔
374
        if impStr := imps.Format(); len(impStr) > 0 {
2,036✔
375
                _, _ = fmt.Fprintf(out, "%s\n", impStr)
1,008✔
376
        }
1,008✔
377
}
378

379
// writeFile writes to the given folder and filename, formatting the buffer
380
// given.
381
// If goVersion is empty, the file is not formatted.
382
func writeFile(outFolder string, fileName string, input io.Reader, goVersion string) error {
1,030✔
383
        var byt []byte
1,030✔
384
        var err error
1,030✔
385
        if goVersion != "" {
2,060✔
386
                byt, err = formatBuffer(input, goVersion)
1,030✔
387
                if err != nil {
1,030✔
388
                        return err
×
389
                }
×
390
        } else {
×
391
                byt, err = io.ReadAll(input)
×
392
                if err != nil {
×
393
                        return err
×
394
                }
×
395
        }
396

397
        path := filepath.Join(outFolder, fileName)
1,030✔
398
        if err := testHarnessWriteFile(path, byt, 0o664); err != nil {
1,030✔
399
                return fmt.Errorf("failed to write output file %s: %w", path, err)
×
400
        }
×
401

402
        return nil
1,030✔
403
}
404

405
// executeTemplate takes a template and returns the output of the template
406
// execution.
407
func executeTemplate[T, C, I any](buf io.Writer, t *template.Template, name string, data *TemplateData[T, C, I]) (err error) {
6,584✔
408
        defer func() {
13,168✔
409
                if r := recover(); r != nil {
6,584✔
410
                        err = fmt.Errorf("failed to execute template: %s\npanic: %+v\n", name, r)
×
411
                }
×
412
        }()
413

414
        if err := t.ExecuteTemplate(buf, name, data); err != nil {
6,584✔
415
                return fmt.Errorf("failed to execute template: %s: %w", name, err)
×
416
        }
×
417
        return nil
6,584✔
418
}
419

420
func formatBuffer(buf io.Reader, version string) ([]byte, error) {
1,032✔
421
        src, err := io.ReadAll(buf)
1,032✔
422
        if err != nil {
1,032✔
423
                return nil, err
×
424
        }
×
425

426
        output, err := format.Source(src, format.Options{LangVersion: version})
1,032✔
427
        if err == nil {
2,062✔
428
                return output, nil
1,030✔
429
        }
1,030✔
430

431
        matches := rgxSyntaxError.FindStringSubmatch(err.Error())
2✔
432
        if matches == nil {
2✔
433
                return nil, fmt.Errorf("failed to format template: %w", err)
×
434
        }
×
435

436
        lineNum, _ := strconv.Atoi(matches[1])
2✔
437
        scanner := bufio.NewScanner(bytes.NewBuffer(src))
2✔
438
        errBuf := &bytes.Buffer{}
2✔
439
        line := 1
2✔
440
        for ; scanner.Scan(); line++ {
8✔
441
                if delta := line - lineNum; delta < -5 || delta > 5 {
6✔
442
                        continue
×
443
                }
444

445
                if line == lineNum {
8✔
446
                        errBuf.WriteString(">>>> ")
2✔
447
                } else {
6✔
448
                        fmt.Fprintf(errBuf, "% 4d ", line)
4✔
449
                }
4✔
450
                errBuf.Write(scanner.Bytes())
6✔
451
                errBuf.WriteByte('\n')
6✔
452
        }
453

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

457
func getLongExt(filename string) string {
308✔
458
        index := strings.IndexByte(filename, '.')
308✔
459
        return filename[index:]
308✔
460
}
308✔
461

462
func getOutputFilename(schema, tableName string, isGo bool) string {
1,264✔
463
        output := tableName
1,264✔
464
        if strings.HasPrefix(output, "_") {
1,266✔
465
                output = "und" + output
2✔
466
        }
2✔
467

468
        if isGo && endsWithSpecialSuffix(output) {
1,272✔
469
                output += "_model"
8✔
470
        }
8✔
471

472
        if schema != "" {
1,602✔
473
                output += "." + schema
338✔
474
        }
338✔
475

476
        return output
1,264✔
477
}
478

479
// See: https://pkg.go.dev/cmd/go#hdr-Build_constraints
480
func endsWithSpecialSuffix(tableName string) bool {
1,262✔
481
        parts := strings.Split(tableName, "_")
1,262✔
482

1,262✔
483
        // Not enough parts to have a special suffix
1,262✔
484
        if len(parts) < 2 {
1,698✔
485
                return false
436✔
486
        }
436✔
487

488
        lastPart := parts[len(parts)-1]
826✔
489

826✔
490
        if lastPart == "test" {
828✔
491
                return true
2✔
492
        }
2✔
493

494
        if _, ok := goosList[lastPart]; ok {
828✔
495
                return true
4✔
496
        }
4✔
497

498
        if _, ok := goarchList[lastPart]; ok {
822✔
499
                return true
2✔
500
        }
2✔
501

502
        return false
818✔
503
}
504

505
func stringSliceToMap(slice []string) map[string]struct{} {
24✔
506
        Map := make(map[string]struct{}, len(slice))
24✔
507
        for _, v := range slice {
516✔
508
                Map[v] = struct{}{}
492✔
509
        }
492✔
510

511
        return Map
24✔
512
}
513

514
// fileFragments will take something of the form:
515
// templates/singleton/hello.go.tpl
516
// templates_test/js/hello.js.tpl
517
//
518
//nolint:nonamedreturns
519
func outputFilenameParts(filename string) (normalized string, isSingleton, isGo bool) {
486✔
520
        fragments := strings.Split(filename, string(os.PathSeparator))
486✔
521
        isSingleton = len(fragments) > 1 && fragments[len(fragments)-2] == "singleton"
486✔
522

486✔
523
        var remainingFragments []string
486✔
524
        for _, f := range fragments {
1,150✔
525
                if f != "singleton" {
1,156✔
526
                        remainingFragments = append(remainingFragments, f)
492✔
527
                }
492✔
528
        }
529

530
        newFilename := remainingFragments[len(remainingFragments)-1]
486✔
531
        newFilename = strings.TrimSuffix(newFilename, ".tpl")
486✔
532
        newFilename = rgxRemoveNumberedPrefix.ReplaceAllString(newFilename, "")
486✔
533
        ext := filepath.Ext(newFilename)
486✔
534
        isGo = ext == ".go"
486✔
535

486✔
536
        remainingFragments[len(remainingFragments)-1] = newFilename
486✔
537
        normalized = strings.Join(remainingFragments, string(os.PathSeparator))
486✔
538

486✔
539
        if isSingleton {
658✔
540
                fNameWithoutExt := newFilename[:len(newFilename)-len(ext)]
172✔
541
                if !strings.HasSuffix(fNameWithoutExt, ".bob") &&
172✔
542
                        !strings.HasSuffix(fNameWithoutExt, ".bob_test") {
172✔
543
                        panic(fmt.Sprintf("singleton file name must end with .bob or .bob_test: %s", filename))
×
544
                }
545
        }
546

547
        return normalized, isSingleton, isGo
486✔
548
}
549

550
type dirExtMap map[string]map[string][]string
551

552
// groupTemplates takes templates and groups them according to their output directory
553
// and file extension.
554
func groupTemplates(templates *template.Template) dirExtMap {
40✔
555
        tplNames := templates.Templates()
40✔
556
        dirs := make(map[string]map[string][]string)
40✔
557
        for _, tpl := range tplNames {
508✔
558
                if !strings.HasSuffix(tpl.Name(), ".tpl") {
628✔
559
                        continue
160✔
560
                }
561

562
                normalized, isSingleton, _ := outputFilenameParts(tpl.Name())
308✔
563
                if isSingleton {
308✔
UNCOV
564
                        continue
×
565
                }
566

567
                dir := filepath.Dir(normalized)
308✔
568
                if dir == "." {
616✔
569
                        dir = ""
308✔
570
                }
308✔
571

572
                extensions, ok := dirs[dir]
308✔
573
                if !ok {
348✔
574
                        extensions = make(map[string][]string)
40✔
575
                        dirs[dir] = extensions
40✔
576
                }
40✔
577

578
                ext := getLongExt(tpl.Name())
308✔
579
                ext = strings.TrimSuffix(ext, ".tpl")
308✔
580
                slice := extensions[ext]
308✔
581
                extensions[ext] = append(slice, tpl.Name())
308✔
582
        }
583

584
        for _, exts := range dirs {
80✔
585
                for _, tplNames := range exts {
80✔
586
                        slices.Sort(tplNames)
40✔
587
                }
40✔
588
        }
589

590
        return dirs
40✔
591
}
592

593
// normalizeSlashes takes a path that was made on linux or windows and converts it
594
// to a native path.
595
func normalizeSlashes(path string) string {
476✔
596
        path = strings.ReplaceAll(path, `/`, string(os.PathSeparator))
476✔
597
        path = strings.ReplaceAll(path, `\`, string(os.PathSeparator))
476✔
598
        return path
476✔
599
}
476✔
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