Skip to content

Commit

Permalink
Add GitHub output format (#273)
Browse files Browse the repository at this point in the history
This will have violations printed in PRs as annotations
on the source code at the location of the violation.

In addition to this, the GitHub output format also prints
the summary as a "Job Summary", which is a rather nice touch!

Fixes #260

Signed-off-by: Anders Eknert <anders@styra.com>
  • Loading branch information
anderseknert authored Aug 24, 2023
1 parent 2b3d7ed commit 1fdedad
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 4 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ jobs:
- run: go test ./...
- run: go build
- run: chmod +x regal
- run: ./regal lint bundle
- run: ./regal lint --format github bundle
- run: go test -tags e2e ./e2e
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,19 @@ Note that at this point in time, Regal only considers the line following the ign
entire blocks of code (like rules, functions or even packages). See [configuration](#configuration) if you want to
ignore certain rules altogether.

## Output Formats

The `regal lint` command allows specifying the output format by using the `--format` flag. The available output formats
are:

- `pretty` (default) - Human-readable table-like output where each violation is printed with a detailed explanation
- `compact` - Human-readable output where each violation is printed on a single line
- `json` - JSON output, suitable for programmatic consumption
- `github` - GitHub [workflow command](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions)
output, ideal for use in GitHub Actions. Annotates PRs and creates a
[job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary)
from the linter report

## Resources

### Documentation
Expand Down
2 changes: 2 additions & 0 deletions cmd/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ const (
formatPretty = "pretty"
// formatCompact is the compact format value for the --format flag in various commands.
formatCompact = "compact"
// formatGitHub is the GitHub format value for the --format flag in various commands.
formatGitHub = "github"
)
4 changes: 3 additions & 1 deletion cmd/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func init() {
lintCommand.Flags().StringVarP(&params.configFile, "config-file", "c", "",
"set path of configuration file")
lintCommand.Flags().StringVarP(&params.format, "format", "f", formatPretty,
"set output format (pretty, compact, json)")
"set output format (pretty, compact, json, github)")
lintCommand.Flags().StringVarP(&params.outputFile, "output-file", "o", "",
"set file to use for linting output, defaults to stdout")
lintCommand.Flags().StringVarP(&params.failLevel, "fail-level", "l", "error",
Expand Down Expand Up @@ -270,6 +270,8 @@ func getReporter(format string, outputWriter io.Writer) (reporter.Reporter, erro
return reporter.NewCompactReporter(outputWriter), nil
case formatJSON:
return reporter.NewJSONReporter(outputWriter), nil
case formatGitHub:
return reporter.NewGitHubReporter(outputWriter), nil
default:
return nil, fmt.Errorf("unknown format %s", format)
}
Expand Down
93 changes: 92 additions & 1 deletion pkg/reporter/reporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/fatih/color"
Expand Down Expand Up @@ -33,6 +34,11 @@ type JSONReporter struct {
out io.Writer
}

// GitHubReporter reports violations in a format suitable for GitHub Actions.
type GitHubReporter struct {
out io.Writer
}

// NewPrettyReporter creates a new PrettyReporter.
func NewPrettyReporter(out io.Writer) PrettyReporter {
return PrettyReporter{out: out}
Expand All @@ -48,6 +54,11 @@ func NewJSONReporter(out io.Writer) JSONReporter {
return JSONReporter{out: out}
}

// NewGitHubReporter creates a new GitHubReporter.
func NewGitHubReporter(out io.Writer) GitHubReporter {
return GitHubReporter{out: out}
}

// Publish prints a pretty report to the configured output.
func (tr PrettyReporter) Publish(r report.Report) error {
table := buildPrettyViolationsTable(r.Violations)
Expand Down Expand Up @@ -135,7 +146,7 @@ func (tr CompactReporter) Publish(r report.Report) error {
table.AddRow(violation.Location.String(), violation.Description)
}

_, err := fmt.Fprintln(tr.out, table)
_, err := fmt.Fprintln(tr.out, strings.TrimSuffix(table.String(), " "))

return err //nolint:wrapcheck
}
Expand All @@ -156,6 +167,77 @@ func (tr JSONReporter) Publish(r report.Report) error {
return err //nolint:wrapcheck
}

//nolint:nestif
func (tr GitHubReporter) Publish(r report.Report) error {
if r.Violations == nil {
r.Violations = []report.Violation{}
}

for _, violation := range r.Violations {
_, err := fmt.Fprintf(tr.out,
"::%s file=%s,line=%d,col=%d::%s\n",
violation.Level,
violation.Location.File,
violation.Location.Row,
violation.Location.Column,
fmt.Sprintf("%s. To learn more, see: %s", violation.Description, getDocumentationURL(violation)),
)
if err != nil {
return err //nolint:wrapcheck
}
}

pluralScanned := ""
if r.Summary.FilesScanned == 0 || r.Summary.FilesScanned > 1 {
pluralScanned = "s"
}

// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary
if summaryFileLoc, ok := os.LookupEnv("GITHUB_STEP_SUMMARY"); ok && summaryFileLoc != "" {
summaryFile, err := os.OpenFile(summaryFileLoc, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err //nolint:wrapcheck
}

defer func() {
_ = summaryFile.Close()
}()

fmt.Fprintf(summaryFile, "### Regal Lint Report\n\n")

fmt.Fprintf(summaryFile, "%d file%s linted.", r.Summary.FilesScanned, pluralScanned)

if r.Summary.NumViolations == 0 { //nolint:nestif
fmt.Fprintf(summaryFile, " No violations found")
} else {
pluralViolations := ""
if r.Summary.NumViolations > 1 {
pluralViolations = "s"
}

fmt.Fprintf(summaryFile, " %d violation%s found", r.Summary.NumViolations, pluralViolations)

if r.Summary.FilesScanned > 1 && r.Summary.FilesFailed > 0 {
pluralFailed := ""
if r.Summary.FilesFailed > 1 {
pluralFailed = "s"
}

fmt.Fprintf(summaryFile, " in %d file%s.", r.Summary.FilesFailed, pluralFailed)
fmt.Fprintf(summaryFile, " See Files tab in PR for locations and details.\n\n")

fmt.Fprintf(summaryFile, "#### Violations\n\n")

for description, url := range getUniqueViolationURLs(r.Violations) {
fmt.Fprintf(summaryFile, "* [%s](%s)\n", description, url)
}
}
}
}

return nil
}

func getDocumentationURL(violation report.Violation) string {
for _, resource := range violation.RelatedResources {
if resource.Description == "documentation" {
Expand All @@ -165,3 +247,12 @@ func getDocumentationURL(violation report.Violation) string {

return ""
}

func getUniqueViolationURLs(violations []report.Violation) map[string]string {
urls := make(map[string]string)
for _, violation := range violations {
urls[violation.Description] = getDocumentationURL(violation)
}

return urls
}
45 changes: 44 additions & 1 deletion pkg/reporter/reporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func TestCompactReporterPublish(t *testing.T) {
}

expect := `a.rego:1:1 Rego must not break the law!
b.rego:22:18 Questionable decision found
b.rego:22:18 Questionable decision found
`

if buf.String() != expect {
Expand Down Expand Up @@ -221,3 +221,46 @@ func TestJSONReporterPublishNoViolations(t *testing.T) {
t.Errorf("expected %q, got %q", `{"violations":[]}`, buf.String())
}
}

//nolint:paralleltest
func TestGitHubReporterPublish(t *testing.T) {
// Can't use t.Parallel() here because t.Setenv() forbids that
t.Setenv("GITHUB_STEP_SUMMARY", "")

var buf bytes.Buffer

cr := NewGitHubReporter(&buf)

err := cr.Publish(rep)
if err != nil {
t.Fatal(err)
}

//nolint:lll
expect := `::error file=a.rego,line=1,col=1::Rego must not break the law!. To learn more, see: https://example.com/illegal
::warning file=b.rego,line=22,col=18::Questionable decision found. To learn more, see: https://example.com/questionable
`

if buf.String() != expect {
t.Errorf("expected %q, got %q", expect, buf.String())
}
}

//nolint:paralleltest
func TestGitHubReporterPublishNoViolations(t *testing.T) {
// Can't use t.Parallel() here because t.Setenv() forbids that
t.Setenv("GITHUB_STEP_SUMMARY", "")

var buf bytes.Buffer

cr := NewGitHubReporter(&buf)

err := cr.Publish(report.Report{})
if err != nil {
t.Fatal(err)
}

if buf.String() != "" {
t.Errorf("expected %q, got %q", "", buf.String())
}
}

0 comments on commit 1fdedad

Please sign in to comment.