Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '31 7 * * 3'

Expand Down
2 changes: 2 additions & 0 deletions internal/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ func TestNewLintCommand_Flags(t *testing.T) {
{"deadcode-exclude", "string"},
{"no-gofumpt", "bool"},
{"no-golangci", "bool"},
{"new-from-rev", "string"},
// Shared linter config flags
{"max-line-width", "int"},
{"tab-width", "int"},
Expand Down Expand Up @@ -247,6 +248,7 @@ func TestNewLintCommand_FlagDefaults(t *testing.T) {
stringDefaults := map[string]string{
"paths": "",
"deadcode-exclude": "",
"new-from-rev": "",
"exclude": "",
"rule": "",
}
Expand Down
4 changes: 4 additions & 0 deletions internal/commands/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ func bindLintFlags(cmd *cobra.Command, opts *lint.LintOpts) {
)
fl.BoolVar(&opts.NoGofumpt, "no-gofumpt", false, "skip gofumpt")
fl.BoolVar(&opts.NoGolangci, "no-golangci", false, "skip golangci-lint")
fl.StringVar(
&opts.NewFromRev, "new-from-rev", "",
"only report new golangci-lint issues since this git revision",
)
}

// bindLinterConfigFlags binds the shared linter configuration
Expand Down
45 changes: 31 additions & 14 deletions internal/services/covgate/covgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"runtime"
"strings"
"sync"
"time"

"github.com/mirurobotics/gotools/internal/services/gocover"
)
Expand Down Expand Up @@ -99,14 +100,16 @@ func (r *runner) runPackages(

func (r *runner) printResults(w io.Writer, results []checkResult) error {
hasFailures := false
var total time.Duration
for _, res := range results {
_, _ = fmt.Fprint(w, res.output)
total += res.duration
if !res.passed {
hasFailures = true
}
}

_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintf(w, "\nTotal time: %s\n", fmtDuration(total))
if hasFailures {
_, _ = fmt.Fprintln(
w, "ERROR: One or more packages failed "+
Expand All @@ -121,19 +124,20 @@ func (r *runner) printResults(w io.Writer, results []checkResult) error {

func printHeader(w io.Writer) {
_, _ = fmt.Fprintf(
w, "%-6s %8s %8s %s\n",
"STATUS", "COVERAGE", "REQUIRED", "PACKAGE",
w, "%-6s %8s %8s %8s %s\n",
"STATUS", "COVERAGE", "REQUIRED", "TIME", "PACKAGE",
)
_, _ = fmt.Fprintf(
w, "%-6s %8s %8s %s\n",
"------", "--------", "--------", "-------",
w, "%-6s %8s %8s %8s %s\n",
"------", "--------", "--------", "--------", "-------",
)
}

// checkResult holds the output and pass/fail status for a single package check.
type checkResult struct {
output string // formatted line(s) to print
passed bool
output string // formatted line(s) to print
passed bool
duration time.Duration
}

// checkPackageCtx holds the per-run constants passed to checkPackage.
Expand All @@ -151,27 +155,40 @@ func (r *runner) checkPackage(pkg string, ctx checkPackageCtx) checkResult {

testPaths := gocover.BuildTestPaths(pkg, relPkg, ctx.srcPrefix, ctx.testDir)

var b strings.Builder
start := time.Now()
coverage, output, testErr := r.measure(pkg, testPaths)
elapsed := time.Since(start)

var b strings.Builder
if testErr != nil {
_, _ = fmt.Fprintf(
&b, "%-6s %8s %8s %s\n",
"FAIL", "---", "---",
&b, "%-6s %8s %8s %8s %s\n",
"FAIL", "---", "---", fmtDuration(elapsed),
relPkg+" (tests failed)",
)
_, _ = fmt.Fprintln(&b)
_, _ = fmt.Fprint(&b, string(output))
_, _ = fmt.Fprintln(&b)
return checkResult{output: b.String(), passed: false}
return checkResult{b.String(), false, elapsed}
}

status := "PASS"
if coverage < threshold {
status = "FAIL"
}
_, _ = fmt.Fprintf(
&b, "%-6s %7.1f%% %7.1f%% %s\n",
status, coverage, threshold, relPkg,
&b, "%-6s %7.1f%% %7.1f%% %8s %s\n",
status, coverage, threshold, fmtDuration(elapsed), relPkg,
)
return checkResult{output: b.String(), passed: coverage >= threshold}
return checkResult{b.String(), coverage >= threshold, elapsed}
}

func fmtDuration(d time.Duration) string {
d = d.Round(100 * time.Millisecond)
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
m := int(d.Minutes())
s := d - time.Duration(m)*time.Minute
return fmt.Sprintf("%dm%02.0fs", m, s.Seconds())
}
64 changes: 63 additions & 1 deletion internal/services/covgate/covgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/mirurobotics/gotools/internal/services/gocover"
"github.com/mirurobotics/gotools/internal/testutil"
Expand All @@ -17,7 +18,7 @@ func TestPrintHeader(t *testing.T) {
printHeader(&buf)
out := buf.String()

cols := []string{"STATUS", "COVERAGE", "REQUIRED", "PACKAGE"}
cols := []string{"STATUS", "COVERAGE", "REQUIRED", "TIME", "PACKAGE"}
for _, col := range cols {
if !strings.Contains(out, col) {
t.Errorf("output missing column %q", col)
Expand Down Expand Up @@ -373,3 +374,64 @@ func TestRun_GoListError(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
}

func TestFmtDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{0, "0.0s"},
{500 * time.Millisecond, "0.5s"},
{3200 * time.Millisecond, "3.2s"},
{60 * time.Second, "1m00s"},
{7*time.Minute + 45*time.Second, "7m45s"},
}
for _, tt := range tests {
if got := fmtDuration(tt.d); got != tt.want {
t.Errorf("fmtDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}

func TestRun_OutputContainsTiming(t *testing.T) {
testutil.MakePkgDir(t, "pkg/a")

var buf bytes.Buffer
//nolint:exhaustruct // test uses partial initialization
r := runner{
goModule: func() (string, error) { return modName, nil },
goListPackages: func(string) ([]string, error) {
return []string{"example.com/mod/pkg/a"}, nil
},
measure: fakeMeasure(90.0),
}

//nolint:exhaustruct // test uses partial initialization
err := r.run(Opts{Out: &buf, DefaultThreshold: 80.0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
s := buf.String()
if !strings.Contains(s, "TIME") {
t.Errorf("output missing TIME column header: %s", s)
}
if !strings.Contains(s, "Total time:") {
t.Errorf("output missing total time: %s", s)
}
}

func TestCheckPackage_OutputContainsTime(t *testing.T) {
testutil.MakePkgDir(t, pkgRel)

//nolint:exhaustruct // test uses partial initialization
r := runner{measure: fakeMeasure(85.0)}

//nolint:exhaustruct // test uses partial initialization
res := r.checkPackage(pkgName, checkPackageCtx{module: modName, threshold: 80.0})
if !strings.Contains(res.output, "0.0s") {
t.Errorf("output missing duration: %s", res.output)
}
if res.duration < 0 {
t.Errorf("expected non-negative duration, got %v", res.duration)
}
}
81 changes: 68 additions & 13 deletions internal/services/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import (
"os"
"os/exec"
"strings"
"time"

"github.com/mirurobotics/gotools/internal/services/cmdutil"
"github.com/mirurobotics/gotools/internal/services/lint/linter"
)

type stepTiming struct {
name string
duration time.Duration
}

// LintOpts holds the options for the lint orchestrator.
type LintOpts struct {
Paths string
Expand All @@ -21,6 +27,7 @@ type LintOpts struct {
DeadcodeExclude string
NoGofumpt bool
NoGolangci bool
NewFromRev string
Out io.Writer
Err io.Writer
}
Expand All @@ -35,42 +42,84 @@ func RunLint(opts LintOpts) error {
opts.Err = os.Stderr
}

var failures []string
totalStart := time.Now()
failures, timings, fatal := runLintSteps(opts)
printTimings(opts.Out, timings, time.Since(totalStart))

if fatal != nil {
return fatal
}
if len(failures) > 0 {
return fmt.Errorf("lint failed: %s", strings.Join(failures, ", "))
}

_, _ = fmt.Fprintln(opts.Out, "Lint complete")
return nil
}

func runLintSteps(
opts LintOpts,
) (failures []string, timings []stepTiming, fatal error) {
if opts.Paths != "" {
start := time.Now()
hadIssues, err := runCustomLinter(opts)
timings = append(timings, stepTiming{"custom linter", time.Since(start)})
if err != nil {
return err
return failures, timings, err
}
if hadIssues {
failures = append(failures, "custom linter")
}
}

if !opts.NoGofumpt {
if err := RunGofumpt(opts.Out, opts.Err, opts.DoFix); err != nil {
return fmt.Errorf("gofumpt: %w", err)
start := time.Now()
err := RunGofumpt(opts.Out, opts.Err, opts.DoFix)
timings = append(timings, stepTiming{"gofumpt", time.Since(start)})
if err != nil {
return failures, timings, fmt.Errorf("gofumpt: %w", err)
}
}

if !opts.NoGolangci {
if err := RunGolangci(opts.Out, opts.Err); err != nil {
start := time.Now()
err := RunGolangci(opts.Out, opts.Err, opts.NewFromRev)
timings = append(timings, stepTiming{"golangci-lint", time.Since(start)})
if err != nil {
failures = append(failures, "golangci-lint")
}
}

if opts.Deadcode {
if err := RunDeadcode(opts.Out, opts.Err, opts.DeadcodeExclude); err != nil {
start := time.Now()
err := RunDeadcode(opts.Out, opts.Err, opts.DeadcodeExclude)
timings = append(timings, stepTiming{"deadcode", time.Since(start)})
if err != nil {
failures = append(failures, "deadcode")
}
}

if len(failures) > 0 {
return fmt.Errorf("lint failed: %s", strings.Join(failures, ", "))
return failures, timings, nil
}

func printTimings(w io.Writer, timings []stepTiming, total time.Duration) {
_, _ = fmt.Fprintln(w)
_, _ = fmt.Fprintln(w, "Timings")
_, _ = fmt.Fprintln(w, "-------")
for _, t := range timings {
_, _ = fmt.Fprintf(w, " %-16s %s\n", t.name, fmtDuration(t.duration))
}
_, _ = fmt.Fprintf(w, " %-16s %s\n", "total", fmtDuration(total))
}

_, _ = fmt.Fprintln(opts.Out, "\nLint complete")
return nil
func fmtDuration(d time.Duration) string {
d = d.Round(100 * time.Millisecond)
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
m := int(d.Minutes())
s := d - time.Duration(m)*time.Minute
return fmt.Sprintf("%dm%02.0fs", m, s.Seconds())
}

func runCustomLinter(opts LintOpts) (bool, error) {
Expand Down Expand Up @@ -103,10 +152,16 @@ func runCustomLinter(opts LintOpts) (bool, error) {
return totalDiags > 0, nil
}

// RunGolangci runs golangci-lint.
func RunGolangci(out io.Writer, errW io.Writer) error {
// RunGolangci runs golangci-lint. If newFromRev is
// non-empty, only new issues since that revision are
// reported.
func RunGolangci(out io.Writer, errW io.Writer, newFromRev string) error {
_, _ = fmt.Fprintln(out, "Running golangci-lint...")
if err := RunExternal(out, errW, "go", "tool", "golangci-lint", "run"); err != nil {
args := []string{"tool", "golangci-lint", "run"}
if newFromRev != "" {
args = append(args, "--new-from-rev="+newFromRev)
}
if err := RunExternal(out, errW, "go", args...); err != nil {
_, _ = fmt.Fprintf(errW, "golangci-lint failed: %v\n", err)
return err
}
Expand Down
Loading