diff --git a/cmd/build.go b/cmd/build.go index 661caee..e1a41aa 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -154,6 +154,7 @@ func init() { buildCmd.Flags().String("serve", "", "After a successful build this starts a webserver on the given address serving the build result (e.g. --serve localhost:8080)") buildCmd.Flags().String("save", "", "After a successful build this saves the build result as tar.gz file in the local filesystem (e.g. --save build-result.tar.gz)") buildCmd.Flags().Bool("watch", false, "Watch source files and re-build on change") + buildCmd.Flags().String("report", "", "Generate a markdown report after the build has finished. (e.g. --report myreport.md)") } func addBuildFlags(cmd *cobra.Command) { @@ -172,7 +173,6 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().UintP("max-concurrent-tasks", "j", uint(runtime.NumCPU()), "Limit the number of max concurrent build tasks - set to 0 to disable the limit") cmd.Flags().String("coverage-output-path", "", "Output path where test coverage file will be copied after running tests") cmd.Flags().StringToString("docker-build-options", nil, "Options passed to all 'docker build' commands") - } func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, *leeway.FilesystemCache) { @@ -257,6 +257,14 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, *leeway.FilesystemC reporter = leeway.NewConsoleReporter() } + report, err := cmd.Flags().GetString("report") + if err != nil { + log.Fatal(err) + } + if report != "" { + reporter = leeway.NewMarkdownReporter(reporter, report) + } + dontTest, err := cmd.Flags().GetBool("dont-test") if err != nil { log.Fatal(err) diff --git a/pkg/leeway/reporter.go b/pkg/leeway/reporter.go index 92cd37b..104c421 100644 --- a/pkg/leeway/reporter.go +++ b/pkg/leeway/reporter.go @@ -8,8 +8,11 @@ import ( "strings" "sync" "text/tabwriter" + "text/template" "time" + log "github.com/sirupsen/logrus" + "github.com/gookit/color" "github.com/segmentio/textio" ) @@ -24,7 +27,7 @@ type Reporter interface { // have been built. BuildStarted(pkg *Package, status map[*Package]PackageBuildStatus) - // BuildFinished is called when the build of a package whcih was started by the user has finished. + // BuildFinished is called when the build of a package which was started by the user has finished. // This is not the same as a dependency build finished (see PackageBuildFinished for that). // The root package will also be passed into PackageBuildFinished once it's been built. BuildFinished(pkg *Package, err error) @@ -225,3 +228,148 @@ func (r *WerftReporter) PackageBuildFinished(pkg *Package, err error) { } fmt.Printf("[%s|%s] %s\n", pkg.FullName(), status, msg) } + +type PackageReport struct { + logs strings.Builder + start time.Time + duration time.Duration + status PackageBuildStatus + err error +} + +func (r *PackageReport) StatusIcon() string { + if r.HasError() { + return "❌" + } + switch r.status { + case PackageBuilt: + return "✅" + case PackageBuilding: + return "🏃" + case PackageNotBuiltYet: + return "🔧" + default: + return "?" + } +} + +func (r *PackageReport) HasError() bool { + return r.err != nil +} + +func (r *PackageReport) DurationInSeconds() string { + return fmt.Sprintf("%.2fs", r.duration.Seconds()) +} + +func (r *PackageReport) Logs() string { + return strings.TrimSpace(r.logs.String()) +} + +func (r *PackageReport) Error() string { + return fmt.Sprintf("%s", r.err) +} + +type MarkdownReporter struct { + delegate Reporter + filename string + reports map[string]*PackageReport + rootPackage *Package + mu sync.RWMutex +} + +func NewMarkdownReporter(del Reporter, filename string) *MarkdownReporter { + return &MarkdownReporter{ + delegate: del, + filename: filename, + reports: make(map[string]*PackageReport), + } +} + +func (r *MarkdownReporter) getReport(pkg *Package) *PackageReport { + name := pkg.FullName() + + r.mu.RLock() + rep, ok := r.reports[name] + r.mu.RUnlock() + + if !ok { + r.mu.Lock() + rep, ok = r.reports[name] + if ok { + r.mu.Unlock() + return rep + } + + rep = &PackageReport{status: PackageNotBuiltYet} + r.reports[name] = rep + r.mu.Unlock() + } + + return rep +} + +func (r *MarkdownReporter) BuildStarted(pkg *Package, status map[*Package]PackageBuildStatus) { + r.rootPackage = pkg + r.delegate.BuildStarted(pkg, status) +} + +func (r *MarkdownReporter) BuildFinished(pkg *Package, err error) { + r.delegate.BuildFinished(pkg, err) + r.Report() +} + +func (r *MarkdownReporter) PackageBuildStarted(pkg *Package) { + r.delegate.PackageBuildStarted(pkg) + rep := r.getReport(pkg) + rep.start = time.Now() + rep.status = PackageBuilding +} + +func (r *MarkdownReporter) PackageBuildLog(pkg *Package, isErr bool, buf []byte) { + report := r.getReport(pkg) + report.logs.Write(buf) + r.delegate.PackageBuildLog(pkg, isErr, buf) +} + +func (r *MarkdownReporter) PackageBuildFinished(pkg *Package, err error) { + r.delegate.PackageBuildFinished(pkg, err) + rep := r.getReport(pkg) + rep.duration = time.Since(rep.start) + rep.status = PackageBuilt + rep.err = err +} + +func (r *MarkdownReporter) Report() { + vars := make(map[string]interface{}) + vars["Name"] = r.filename + vars["Packages"] = r.reports + vars["RootPackage"] = r.rootPackage + + tmplString := ` +# Leeway build for '{{ .RootPackage.FullName }}' +{{ range $pkg, $report := .Packages }} + {{ $report.StatusIcon }} {{ $pkg }} - {{ $report.DurationInSeconds }} + +{{ if $report.HasError }} +
+{{ $report.Error }}
+
+{{ end }} + +
+{{ $report.Logs }}
+
+ + +{{ end }} +` + tmpl, _ := template.New("Report").Parse(strings.ReplaceAll(tmplString, "'", "`")) + + file, _ := os.Create(r.filename) + defer file.Close() + + err := tmpl.Execute(file, vars) + if err != nil { + log.WithError(err).Fatal("Can render template") + } +}