diff --git a/.golangci.reference.yml b/.golangci.reference.yml index 241fb7fe4df2..e03c4aada9b7 100644 --- a/.golangci.reference.yml +++ b/.golangci.reference.yml @@ -2339,6 +2339,7 @@ severity: # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#SeverityLevel # - GitHub: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message + # - TeamCity: https://www.jetbrains.com/help/teamcity/service-messages.html#Inspection+Instance # # Default value is an empty string. default-severity: error diff --git a/pkg/commands/run.go b/pkg/commands/run.go index 56d6dfcbf3c4..a9971f5e02b2 100644 --- a/pkg/commands/run.go +++ b/pkg/commands/run.go @@ -494,6 +494,8 @@ func (e *Executor) createPrinter(format string, w io.Writer) (printers.Printer, p = printers.NewJunitXML(w) case config.OutFormatGithubActions: p = printers.NewGithub(w) + case config.OutFormatTeamCity: + p = printers.NewTeamCity(w) default: return nil, fmt.Errorf("unknown output format %s", format) } diff --git a/pkg/config/output.go b/pkg/config/output.go index d67f110f678d..2c49ea7f4e66 100644 --- a/pkg/config/output.go +++ b/pkg/config/output.go @@ -10,6 +10,7 @@ const ( OutFormatHTML = "html" OutFormatJunitXML = "junit-xml" OutFormatGithubActions = "github-actions" + OutFormatTeamCity = "teamcity" ) var OutFormats = []string{ @@ -22,6 +23,7 @@ var OutFormats = []string{ OutFormatHTML, OutFormatJunitXML, OutFormatGithubActions, + OutFormatTeamCity, } type Output struct { diff --git a/pkg/printers/teamcity.go b/pkg/printers/teamcity.go new file mode 100644 index 000000000000..790f30a26fd1 --- /dev/null +++ b/pkg/printers/teamcity.go @@ -0,0 +1,123 @@ +package printers + +import ( + "context" + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/golangci/golangci-lint/pkg/result" +) + +// Field limits. +const ( + smallLimit = 255 + largeLimit = 4000 +) + +// TeamCity printer for TeamCity format. +type TeamCity struct { + w io.Writer + escaper *strings.Replacer +} + +// NewTeamCity output format outputs issues according to TeamCity service message format. +func NewTeamCity(w io.Writer) *TeamCity { + return &TeamCity{ + w: w, + // https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+Values + escaper: strings.NewReplacer( + "'", "|'", + "\n", "|n", + "\r", "|r", + "|", "||", + "[", "|[", + "]", "|]", + ), + } +} + +func (p *TeamCity) Print(_ context.Context, issues []result.Issue) error { + uniqLinters := map[string]struct{}{} + + for i := range issues { + issue := issues[i] + + _, ok := uniqLinters[issue.FromLinter] + if !ok { + inspectionType := InspectionType{ + id: issue.FromLinter, + name: issue.FromLinter, + description: issue.FromLinter, + category: "Golangci-lint reports", + } + + _, err := inspectionType.Print(p.w, p.escaper) + if err != nil { + return err + } + + uniqLinters[issue.FromLinter] = struct{}{} + } + + instance := InspectionInstance{ + typeID: issue.FromLinter, + message: issue.Text, + file: issue.FilePath(), + line: issue.Line(), + severity: issue.Severity, + } + + _, err := instance.Print(p.w, p.escaper) + if err != nil { + return err + } + } + + return nil +} + +// InspectionType is the unique description of the conducted inspection. Each specific warning or +// an error in code (inspection instance) has an inspection type. +// https://www.jetbrains.com/help/teamcity/service-messages.html#Inspection+Type +type InspectionType struct { + id string // (mandatory) limited by 255 characters. + name string // (mandatory) limited by 255 characters. + description string // (mandatory) limited by 255 characters. + category string // (mandatory) limited by 4000 characters. +} + +func (i InspectionType) Print(w io.Writer, escaper *strings.Replacer) (int, error) { + return fmt.Fprintf(w, "##teamcity[InspectionType id='%s' name='%s' description='%s' category='%s']\n", + limit(i.id, smallLimit), limit(i.name, smallLimit), limit(escaper.Replace(i.description), largeLimit), limit(i.category, smallLimit)) +} + +// InspectionInstance reports a specific defect, warning, error message. +// Includes location, description, and various optional and custom attributes. +// https://www.jetbrains.com/help/teamcity/service-messages.html#Inspection+Instance +type InspectionInstance struct { + typeID string // (mandatory) limited by 255 characters. + message string // (optional) limited by 4000 characters. + file string // (mandatory) file path limited by 4000 characters. + line int // (optional) line of the file. + severity string // (optional) any linter severity. +} + +func (i InspectionInstance) Print(w io.Writer, replacer *strings.Replacer) (int, error) { + return fmt.Fprintf(w, "##teamcity[inspection typeId='%s' message='%s' file='%s' line='%d' SEVERITY='%s']\n", + limit(i.typeID, smallLimit), + limit(replacer.Replace(i.message), largeLimit), + limit(i.file, largeLimit), + i.line, strings.ToUpper(i.severity)) +} + +func limit(s string, max int) string { + var size, count int + for i := 0; i < max && count < len(s); i++ { + _, size = utf8.DecodeRuneInString(s[count:]) + count += size + } + + return s[:count] +} diff --git a/pkg/printers/teamcity_test.go b/pkg/printers/teamcity_test.go new file mode 100644 index 000000000000..7f1843e9bec1 --- /dev/null +++ b/pkg/printers/teamcity_test.go @@ -0,0 +1,106 @@ +package printers + +import ( + "bytes" + "context" + "go/token" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/golangci/golangci-lint/pkg/result" +) + +func TestTeamCity_Print(t *testing.T) { + issues := []result.Issue{ + { + FromLinter: "linter-a", + Text: "warning issue", + Pos: token.Position{ + Filename: "path/to/filea.go", + Offset: 2, + Line: 10, + Column: 4, + }, + }, + { + FromLinter: "linter-a", + Severity: "error", + Text: "error issue", + Pos: token.Position{ + Filename: "path/to/filea.go", + Offset: 2, + Line: 10, + }, + }, + { + FromLinter: "linter-b", + Text: "info 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 := NewTeamCity(buf) + + err := printer.Print(context.Background(), issues) + require.NoError(t, err) + + expected := `##teamcity[InspectionType id='linter-a' name='linter-a' description='linter-a' category='Golangci-lint reports'] +##teamcity[inspection typeId='linter-a' message='warning issue' file='path/to/filea.go' line='10' SEVERITY=''] +##teamcity[inspection typeId='linter-a' message='error issue' file='path/to/filea.go' line='10' SEVERITY='ERROR'] +##teamcity[InspectionType id='linter-b' name='linter-b' description='linter-b' category='Golangci-lint reports'] +##teamcity[inspection typeId='linter-b' message='info issue' file='path/to/fileb.go' line='300' SEVERITY=''] +` + + assert.Equal(t, expected, buf.String()) +} + +func TestTeamCity_limit(t *testing.T) { + tests := []struct { + input string + max int + expected string + }{ + { + input: "golangci-lint", + max: 0, + expected: "", + }, + { + input: "golangci-lint", + max: 8, + expected: "golangci", + }, + { + input: "golangci-lint", + max: 13, + expected: "golangci-lint", + }, + { + input: "golangci-lint", + max: 15, + expected: "golangci-lint", + }, + { + input: "こんにちは", + max: 3, + expected: "こんに", + }, + } + + for _, tc := range tests { + require.Equal(t, tc.expected, limit(tc.input, tc.max)) + } +}