Skip to content

Commit

Permalink
feat: add TeamCity output format (#3606)
Browse files Browse the repository at this point in the history
Co-authored-by: Fernandez Ludovic <[email protected]>
Co-authored-by: Oleksandr Redko <[email protected]>
  • Loading branch information
3 people authored Feb 27, 2023
1 parent 998329d commit 075691c
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 0 deletions.
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)
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 = "teamcity"
)

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

type Output struct {
Expand Down
123 changes: 123 additions & 0 deletions pkg/printers/teamcity.go
Original file line number Diff line number Diff line change
@@ -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]
}
106 changes: 106 additions & 0 deletions pkg/printers/teamcity_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}

0 comments on commit 075691c

Please sign in to comment.