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
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)
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
131 changes: 131 additions & 0 deletions pkg/printers/teamcity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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(),
additionalAttribute: strings.TrimSpace(issue.Severity),
}

_, err := instance.Print(p.w, p.escaper)
if err != nil {
return err
}
}

return nil
}

// InspectionType 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, integer.
additionalAttribute string // (optional) can be any attribute.
}

func (i InspectionInstance) Print(w io.Writer, replacer *strings.Replacer) (int, error) {
_, err := fmt.Fprintf(w, "##teamcity[inspection typeId='%s' message='%s' file='%s' line='%d'",
limit(i.typeID, smallLimit), limit(replacer.Replace(i.message), largeLimit), limit(i.file, largeLimit), i.line)
if err != nil {
return 0, err
}

if i.additionalAttribute != "" {
_, err = fmt.Fprintf(w, " additional attribute='%s'", i.additionalAttribute)
if err != nil {
return 0, err
}
}

return fmt.Fprintln(w, "]")
}

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]
}
108 changes: 108 additions & 0 deletions pkg/printers/teamcity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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",
Severity: "error",
Text: "some issue",
Pos: token.Position{
Filename: "path/to/filea.go",
Offset: 2,
Line: 10,
Column: 4,
},
},
{
FromLinter: "linter-a",
Severity: "error",
Text: "some issue 2",
Pos: token.Position{
Filename: "path/to/filea.go",
Offset: 2,
Line: 10,
},
},
{
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 := 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='some issue' file='path/to/filea.go' line='10' additional attribute='error']
##teamcity[inspection typeId='linter-a' message='some issue 2' file='path/to/filea.go' line='10' additional attribute='error']
##teamcity[inspectionType id='linter-b' name='linter-b' description='linter-b' category='Golangci-lint reports']
##teamcity[inspection typeId='linter-b' message='another issue' file='path/to/fileb.go' line='300' additional attribute='error']
`

assert.Equal(t, expected, buf.String())
}

func TestLimit(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))
}
}