diff --git a/.golangci.next.reference.yml b/.golangci.next.reference.yml index 2995ca5d3423..924c66f6fa61 100644 --- a/.golangci.next.reference.yml +++ b/.golangci.next.reference.yml @@ -60,7 +60,18 @@ run: # output configuration options output: # The formats used to render issues. - # Format: `colored-line-number`, `line-number`, `json`, `colored-tab`, `tab`, `checkstyle`, `code-climate`, `junit-xml`, `github-actions`, `teamcity` + # Formats: + # - `colored-line-number` + # - `line-number` + # - `json` + # - `colored-tab` + # - `tab` + # - `checkstyle` + # - `code-climate` + # - `junit-xml` + # - `github-actions` + # - `github-actions-problem-matchers` + # - `teamcity` # Output path can be either `stdout`, `stderr` or path to the file to write to. # # For the CLI flag (`--out-format`), multiple formats can be specified by separating them by comma. diff --git a/.golangci.reference.yml b/.golangci.reference.yml index 2995ca5d3423..924c66f6fa61 100644 --- a/.golangci.reference.yml +++ b/.golangci.reference.yml @@ -60,7 +60,18 @@ run: # output configuration options output: # The formats used to render issues. - # Format: `colored-line-number`, `line-number`, `json`, `colored-tab`, `tab`, `checkstyle`, `code-climate`, `junit-xml`, `github-actions`, `teamcity` + # Formats: + # - `colored-line-number` + # - `line-number` + # - `json` + # - `colored-tab` + # - `tab` + # - `checkstyle` + # - `code-climate` + # - `junit-xml` + # - `github-actions` + # - `github-actions-problem-matchers` + # - `teamcity` # Output path can be either `stdout`, `stderr` or path to the file to write to. # # For the CLI flag (`--out-format`), multiple formats can be specified by separating them by comma. diff --git a/jsonschema/golangci.jsonschema.json b/jsonschema/golangci.jsonschema.json index e7684df3f395..9a42a556fd7c 100644 --- a/jsonschema/golangci.jsonschema.json +++ b/jsonschema/golangci.jsonschema.json @@ -432,6 +432,7 @@ "code-climate", "junit-xml", "github-actions", + "github-actions-problem-matchers", "teamcity" ] } diff --git a/jsonschema/golangci.next.jsonschema.json b/jsonschema/golangci.next.jsonschema.json index e7684df3f395..9a42a556fd7c 100644 --- a/jsonschema/golangci.next.jsonschema.json +++ b/jsonschema/golangci.next.jsonschema.json @@ -432,6 +432,7 @@ "code-climate", "junit-xml", "github-actions", + "github-actions-problem-matchers", "teamcity" ] } diff --git a/pkg/config/output.go b/pkg/config/output.go index a005213cfdce..efab9fee781b 100644 --- a/pkg/config/output.go +++ b/pkg/config/output.go @@ -8,17 +8,18 @@ import ( ) const ( - OutFormatJSON = "json" - OutFormatLineNumber = "line-number" - OutFormatColoredLineNumber = "colored-line-number" - OutFormatTab = "tab" - OutFormatColoredTab = "colored-tab" - OutFormatCheckstyle = "checkstyle" - OutFormatCodeClimate = "code-climate" - OutFormatHTML = "html" - OutFormatJunitXML = "junit-xml" - OutFormatGithubActions = "github-actions" - OutFormatTeamCity = "teamcity" + OutFormatJSON = "json" + OutFormatLineNumber = "line-number" + OutFormatColoredLineNumber = "colored-line-number" + OutFormatTab = "tab" + OutFormatColoredTab = "colored-tab" + OutFormatCheckstyle = "checkstyle" + OutFormatCodeClimate = "code-climate" + OutFormatHTML = "html" + OutFormatJunitXML = "junit-xml" + OutFormatGithubActions = "github-actions" + OutFormatGithubActionsProblemMatchers = "github-actions-problem-matchers" + OutFormatTeamCity = "teamcity" ) var AllOutputFormats = []string{ @@ -32,6 +33,7 @@ var AllOutputFormats = []string{ OutFormatHTML, OutFormatJunitXML, OutFormatGithubActions, + OutFormatGithubActionsProblemMatchers, OutFormatTeamCity, } diff --git a/pkg/printers/githubaction.go b/pkg/printers/githubaction.go new file mode 100644 index 000000000000..0d71b1c9b338 --- /dev/null +++ b/pkg/printers/githubaction.go @@ -0,0 +1,51 @@ +package printers + +import ( + "fmt" + "io" + "path/filepath" + + "github.com/golangci/golangci-lint/pkg/result" +) + +const defaultGithubSeverity = "error" + +type GitHubAction struct { + w io.Writer +} + +// NewGitHubAction output format outputs issues according to GitHub actions. +func NewGitHubAction(w io.Writer) *GitHubAction { + return &GitHubAction{w: w} +} + +func (p *GitHubAction) Print(issues []result.Issue) error { + for ind := range issues { + _, err := fmt.Fprintln(p.w, formatIssueAsGitHub(&issues[ind])) + if err != nil { + return err + } + } + return nil +} + +// print each line as: ::error file=app.js,line=10,col=15::Something went wrong +func formatIssueAsGitHub(issue *result.Issue) string { + severity := defaultGithubSeverity + if issue.Severity != "" { + severity = issue.Severity + } + + // Convert backslashes to forward slashes. + // This is needed when running on windows. + // Otherwise, GitHub won't be able to show the annotations pointing to the file path with backslashes. + file := filepath.ToSlash(issue.FilePath()) + + ret := fmt.Sprintf("::%s file=%s,line=%d", severity, file, issue.Line()) + if issue.Pos.Column != 0 { + ret += fmt.Sprintf(",col=%d", issue.Pos.Column) + } + + ret += fmt.Sprintf("::%s (%s)", issue.Text, issue.FromLinter) + return ret +} diff --git a/pkg/printers/github.go b/pkg/printers/githubaction_problem_matchers.go similarity index 58% rename from pkg/printers/github.go rename to pkg/printers/githubaction_problem_matchers.go index d91353c1f58c..faaa9b39c0e3 100644 --- a/pkg/printers/github.go +++ b/pkg/printers/githubaction_problem_matchers.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strconv" "github.com/golangci/golangci-lint/pkg/result" ) @@ -14,26 +15,26 @@ const defaultGitHubSeverity = "error" const filenameGitHubActionProblemMatchers = "golangci-lint-action-problem-matchers.json" -// GitHubProblemMatchers defines the root of problem matchers. +// GHProblemMatchers defines the root of problem matchers. // - https://github.com/actions/toolkit/blob/main/docs/problem-matchers.md // - https://github.com/actions/toolkit/blob/main/docs/commands.md#problem-matchers -type GitHubProblemMatchers struct { - Matchers []GitHubMatcher `json:"problemMatcher,omitempty"` +type GHProblemMatchers struct { + Matchers []GHMatcher `json:"problemMatcher,omitempty"` } -// GitHubMatcher defines a problem matcher. -type GitHubMatcher struct { +// GHMatcher defines a problem matcher. +type GHMatcher struct { // Owner an ID field that can be used to remove or replace the problem matcher. // **required** Owner string `json:"owner,omitempty"` // Severity indicates the default severity, either 'warning' or 'error' case-insensitive. // Defaults to 'error'. - Severity string `json:"severity,omitempty"` - Pattern []GitHubPattern `json:"pattern,omitempty"` + Severity string `json:"severity,omitempty"` + Pattern []GHPattern `json:"pattern,omitempty"` } -// GitHubPattern defines a pattern for a problem matcher. -type GitHubPattern struct { +// GHPattern defines a pattern for a problem matcher. +type GHPattern struct { // Regexp the regexp pattern that provides the groups to match against. // **required** Regexp string `json:"regexp,omitempty"` @@ -58,37 +59,41 @@ type GitHubPattern struct { Loop bool `json:"loop,omitempty"` } -type GitHub struct { +type GitHubActionProblemMatchers struct { tempPath string w io.Writer } -// NewGitHub output format outputs issues according to GitHub actions the problem matcher regexp. -func NewGitHub(w io.Writer) *GitHub { - return &GitHub{ +// NewGitHubActionProblemMatchers output format outputs issues according to GitHub actions the problem matcher regexp. +func NewGitHubActionProblemMatchers(w io.Writer) *GitHubActionProblemMatchers { + return &GitHubActionProblemMatchers{ tempPath: filepath.Join(os.TempDir(), filenameGitHubActionProblemMatchers), w: w, } } -func (p *GitHub) Print(issues []result.Issue) error { - // Note: the file with the problem matcher definition should not be removed. - // A sleep can mitigate this problem but this will be flaky. - // - // Result if the file is removed prematurely: - // Error: Unable to process command '::add-matcher::/tmp/golangci-lint-action-problem-matchers.json' successfully. - // Error: Could not find file '/tmp/golangci-lint-action-problem-matchers.json'. - filename, err := p.storeProblemMatcher() - if err != nil { - return err - } - - _, _ = fmt.Fprintln(p.w, "::debug::problem matcher definition file: "+filename) +func (p *GitHubActionProblemMatchers) Print(issues []result.Issue) error { + // Used by the official GitHub Action (https://github.com/golangci/golangci-lint-action). + // The problem matchers is embedded inside the GitHub Action to avoid errors: + // https://github.com/golangci/golangci-lint/issues/4695 + if ok, _ := strconv.ParseBool(os.Getenv("GOLANGCI_LINT_SKIP_GHA_PM_INSTALL")); !ok { + // Note: the file with the problem matcher definition should not be removed. + // A sleep can mitigate this problem but this will be flaky. + // + // Result if the file is removed prematurely: + // Error: Unable to process command '::add-matcher::/tmp/golangci-lint-action-problem-matchers.json' successfully. + // Error: Could not find file '/tmp/golangci-lint-action-problem-matchers.json'. + filename, err := p.storeProblemMatcher() + if err != nil { + return err + } - _, _ = fmt.Fprintln(p.w, "::add-matcher::"+filename) + _, _ = fmt.Fprintln(p.w, "::debug::problem matcher definition file: "+filename) + _, _ = fmt.Fprintln(p.w, "::add-matcher::"+filename) + } for ind := range issues { - _, err := fmt.Fprintln(p.w, formatIssueAsGitHub(&issues[ind])) + _, err := fmt.Fprintln(p.w, formatIssueAsProblemMatcher(&issues[ind])) if err != nil { return err } @@ -99,7 +104,7 @@ func (p *GitHub) Print(issues []result.Issue) error { return nil } -func (p *GitHub) storeProblemMatcher() (string, error) { +func (p *GitHubActionProblemMatchers) storeProblemMatcher() (string, error) { file, err := os.Create(p.tempPath) if err != nil { return "", err @@ -115,13 +120,16 @@ func (p *GitHub) storeProblemMatcher() (string, error) { return file.Name(), nil } -func generateProblemMatcher() GitHubProblemMatchers { - return GitHubProblemMatchers{ - Matchers: []GitHubMatcher{ +// generateProblemMatcher generated the problem matchers file. +// Should be synced with the official GitHub Action. +// https://github.com/golangci/golangci-lint-action/blob/master/problem-matchers.json +func generateProblemMatcher() GHProblemMatchers { + return GHProblemMatchers{ + Matchers: []GHMatcher{ { Owner: "golangci-lint-action", Severity: "error", - Pattern: []GitHubPattern{ + Pattern: []GHPattern{ { Regexp: `^([^\s]+)\s+([^:]+):(\d+):(?:(\d+):)?\s+(.+)$`, Severity: 1, @@ -136,7 +144,7 @@ func generateProblemMatcher() GitHubProblemMatchers { } } -func formatIssueAsGitHub(issue *result.Issue) string { +func formatIssueAsProblemMatcher(issue *result.Issue) string { severity := defaultGitHubSeverity if issue.Severity != "" { severity = issue.Severity diff --git a/pkg/printers/github_test.go b/pkg/printers/githubaction_problem_matchers_test.go similarity index 90% rename from pkg/printers/github_test.go rename to pkg/printers/githubaction_problem_matchers_test.go index 240c1b1e00f4..3b54786c231c 100644 --- a/pkg/printers/github_test.go +++ b/pkg/printers/githubaction_problem_matchers_test.go @@ -49,7 +49,7 @@ func TestGitHub_Print(t *testing.T) { buf := new(bytes.Buffer) - printer := NewGitHub(buf) + printer := NewGitHubActionProblemMatchers(buf) printer.tempPath = filepath.Join(t.TempDir(), filenameGitHubActionProblemMatchers) err := printer.Print(issues) @@ -67,7 +67,7 @@ error path/to/fileb.go:300:9: another issue (linter-b) assert.Equal(t, expected, buf.String()) } -func Test_formatIssueAsGitHub(t *testing.T) { +func Test_formatIssueAsProblemMatcher(t *testing.T) { sampleIssue := result.Issue{ FromLinter: "sample-linter", Text: "some issue", @@ -78,13 +78,13 @@ func Test_formatIssueAsGitHub(t *testing.T) { Column: 4, }, } - require.Equal(t, "error\tpath/to/file.go:10:4:\tsome issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + require.Equal(t, "error\tpath/to/file.go:10:4:\tsome issue (sample-linter)", formatIssueAsProblemMatcher(&sampleIssue)) sampleIssue.Pos.Column = 0 - require.Equal(t, "error\tpath/to/file.go:10:\tsome issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + require.Equal(t, "error\tpath/to/file.go:10:\tsome issue (sample-linter)", formatIssueAsProblemMatcher(&sampleIssue)) } -func Test_formatIssueAsGitHub_Windows(t *testing.T) { +func Test_formatIssueAsProblemMatcher_Windows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("Skipping test on non Windows") } @@ -99,10 +99,10 @@ func Test_formatIssueAsGitHub_Windows(t *testing.T) { Column: 4, }, } - require.Equal(t, "error\tpath/to/file.go:10:4:\tsome issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + require.Equal(t, "error\tpath/to/file.go:10:4:\tsome issue (sample-linter)", formatIssueAsProblemMatcher(&sampleIssue)) sampleIssue.Pos.Column = 0 - require.Equal(t, "error\tpath/to/file.go:10:\tsome issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + require.Equal(t, "error\tpath/to/file.go:10:\tsome issue (sample-linter)", formatIssueAsProblemMatcher(&sampleIssue)) } func Test_generateProblemMatcher(t *testing.T) { @@ -158,7 +158,7 @@ Message: Foo bar`, } } -func createReplacement(pattern *GitHubPattern) string { +func createReplacement(pattern *GHPattern) string { var repl []string if pattern.File > 0 { diff --git a/pkg/printers/githubaction_test.go b/pkg/printers/githubaction_test.go new file mode 100644 index 000000000000..d45fdb73dd05 --- /dev/null +++ b/pkg/printers/githubaction_test.go @@ -0,0 +1,95 @@ +package printers + +import ( + "bytes" + "go/token" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestGitHubAction_Print(t *testing.T) { + issues := []result.Issue{ + { + FromLinter: "linter-a", + Severity: "warning", + Text: "some issue", + Pos: token.Position{ + Filename: "path/to/filea.go", + Offset: 2, + Line: 10, + Column: 4, + }, + }, + { + FromLinter: "linter-b", + Severity: "error", + Text: "another issue", + SourceLines: []string{ + "func foo() {", + "\tfmt.Println(\"bar\")", + "}", + }, + Pos: token.Position{ + Filename: "path/to/fileb.go", + Offset: 5, + Line: 300, + Column: 9, + }, + }, + } + + buf := new(bytes.Buffer) + printer := NewGitHubAction(buf) + + err := printer.Print(issues) + require.NoError(t, err) + + expected := `::warning file=path/to/filea.go,line=10,col=4::some issue (linter-a) +::error file=path/to/fileb.go,line=300,col=9::another issue (linter-b) +` + + assert.Equal(t, expected, buf.String()) +} + +func Test_formatIssueAsGitHub(t *testing.T) { + sampleIssue := result.Issue{ + FromLinter: "sample-linter", + Text: "some issue", + Pos: token.Position{ + Filename: "path/to/file.go", + Offset: 2, + Line: 10, + Column: 4, + }, + } + require.Equal(t, "::error file=path/to/file.go,line=10,col=4::some issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + + sampleIssue.Pos.Column = 0 + require.Equal(t, "::error file=path/to/file.go,line=10::some issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) +} + +func Test_formatIssueAsGitHub_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Skipping test on non Windows") + } + + sampleIssue := result.Issue{ + FromLinter: "sample-linter", + Text: "some issue", + Pos: token.Position{ + Filename: "path\\to\\file.go", + Offset: 2, + Line: 10, + Column: 4, + }, + } + require.Equal(t, "::error file=path/to/file.go,line=10,col=4::some issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) + + sampleIssue.Pos.Column = 0 + require.Equal(t, "::error file=path/to/file.go,line=10::some issue (sample-linter)", formatIssueAsGitHub(&sampleIssue)) +} diff --git a/pkg/printers/printer.go b/pkg/printers/printer.go index 08c34234a9b9..95318ebce91e 100644 --- a/pkg/printers/printer.go +++ b/pkg/printers/printer.go @@ -132,7 +132,9 @@ func (c *Printer) createPrinter(format string, w io.Writer) (issuePrinter, error case config.OutFormatJunitXML: p = NewJunitXML(w) case config.OutFormatGithubActions: - p = NewGitHub(w) + p = NewGitHubAction(w) + case config.OutFormatGithubActionsProblemMatchers: + p = NewGitHubActionProblemMatchers(w) case config.OutFormatTeamCity: p = NewTeamCity(w) default: