Skip to content

feat: add TeamCity output format #3606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Feb 27, 2023
1 change: 1 addition & 0 deletions .golangci.reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, e.DBManager)
default:
return nil, fmt.Errorf("unknown output format %s", format)
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const (
OutFormatHTML = "html"
OutFormatJunitXML = "junit-xml"
OutFormatGithubActions = "github-actions"
OutFormatTeamCity = "team-city"
)

var OutFormats = []string{
Expand All @@ -22,6 +23,7 @@ var OutFormats = []string{
OutFormatHTML,
OutFormatJunitXML,
OutFormatGithubActions,
OutFormatTeamCity,
}

type Output struct {
Expand Down
132 changes: 132 additions & 0 deletions pkg/printers/teamcity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package printers

import (
"context"
"fmt"
"io"
"strings"
"unicode/utf8"

"github.com/golangci/golangci-lint/pkg/lint/linter"
"github.com/golangci/golangci-lint/pkg/result"
)

// Field limits.
const (
smallLimit = 255
largeLimit = 4000
)

// Configer used for accessing linter.Config by its name for printing instanceType values.
type Configer interface {
GetLinterConfigs(name string) []*linter.Config
}

// TeamCity printer for TeamCity format.
type TeamCity struct {
w io.Writer
conf Configer
escaper *strings.Replacer
}

// NewTeamCity output format outputs issues according to TeamCity service message format.
func NewTeamCity(w io.Writer, conf Configer) *TeamCity {
return &TeamCity{
w: w,
conf: conf,
// 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 {
linterConfigs := p.conf.GetLinterConfigs(issue.FromLinter)
for _, config := range linterConfigs {
inspectionType := inspectionType{
id: config.Linter.Name(),
name: config.Linter.Name(),
description: config.Linter.Desc(),
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) severity attribute: INFO, ERROR, WARNING, WEAK WARNING.
}

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]
}
139 changes: 139 additions & 0 deletions pkg/printers/teamcity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package printers

import (
"bytes"
"context"
"go/token"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/golangci/golangci-lint/pkg/lint/linter"
"github.com/golangci/golangci-lint/pkg/result"
)

func TestTeamCity_Print(t *testing.T) {
issues := []result.Issue{
{
FromLinter: "linter-a",
Severity: "warning",
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",
Severity: "info",
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, configerMock(
map[string][]*linter.Config{
"linter-a": {
{
Linter: &linterMock{name: "linter-a", desc: "description for linter-a"},
},
},
"linter-b": {
{
Linter: &linterMock{name: "linter-b", desc: "description for linter-b with escape '\n\r|[] characters"},
},
},
},
))

err := printer.Print(context.Background(), issues)
require.NoError(t, err)

//nolint:lll
expected := `##teamcity[inspectionType id='linter-a' name='linter-a' description='description for linter-a' category='Golangci-lint reports']
##teamcity[inspection typeId='linter-a' message='warning issue' file='path/to/filea.go' line='10' SEVERITY='WARNING']
##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='description for linter-b with escape |'|n|r|||[|] characters' category='Golangci-lint reports']
##teamcity[inspection typeId='linter-b' message='info issue' file='path/to/fileb.go' line='300' SEVERITY='INFO']
`

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))
}
}

type configerMock map[string][]*linter.Config

func (c configerMock) GetLinterConfigs(name string) []*linter.Config {
return c[name]
}

type linterMock struct {
linter.Noop
name string
desc string
}

func (l linterMock) Name() string { return l.name }

func (l linterMock) Desc() string { return l.desc }