diff --git a/.github/spellcheck/wordlist.txt b/.github/spellcheck/wordlist.txt index 9ccc50ef..4cd48807 100644 --- a/.github/spellcheck/wordlist.txt +++ b/.github/spellcheck/wordlist.txt @@ -38,6 +38,7 @@ SNI symlink symlinked symlinks +TeamCity templated Thanos TLS diff --git a/cmd/pint/ci.go b/cmd/pint/ci.go index 3f655f43..095e09ce 100644 --- a/cmd/pint/ci.go +++ b/cmd/pint/ci.go @@ -23,6 +23,7 @@ var ( baseBranchFlag = "base-branch" devFlag = "dev" failOnFlag = "fail-on" + teamCityFlag = "teamcity" ) var ciCmd = &cli.Command{ @@ -54,6 +55,12 @@ var ciCmd = &cli.Command{ Value: "bug", Usage: "Exit with non-zero code if there are problems with given severity (or higher) detected", }, + &cli.BoolFlag{ + Name: teamCityFlag, + Aliases: []string{"t"}, + Value: false, + Usage: "Report problems using TeamCity Service Messages", + }, }, } @@ -120,8 +127,12 @@ func actionCI(c *cli.Context) error { summary.Report(verifyOwners(entries, meta.cfg.Owners.CompileAllowed())...) } - reps := []reporter.Reporter{ - reporter.NewConsoleReporter(os.Stderr, checks.Information), + reps := []reporter.Reporter{} + + if c.Bool(teamCityFlag) { + reps = append(reps, reporter.NewTeamCityReporter(os.Stderr)) + } else { + reps = append(reps, reporter.NewConsoleReporter(os.Stderr, checks.Information)) } if meta.cfg.Repository != nil && meta.cfg.Repository.BitBucket != nil { diff --git a/cmd/pint/lint.go b/cmd/pint/lint.go index d402121f..a828e3d9 100644 --- a/cmd/pint/lint.go +++ b/cmd/pint/lint.go @@ -40,6 +40,12 @@ var lintCmd = &cli.Command{ Value: "bug", Usage: "Exit with non-zero code if there are problems with given severity (or higher) detected", }, + &cli.BoolFlag{ + Name: teamCityFlag, + Aliases: []string{"t"}, + Value: false, + Usage: "Report problems using TeamCity Service Messages", + }, }, } @@ -87,7 +93,13 @@ func actionLint(c *cli.Context) error { return fmt.Errorf("invalid --%s value: %w", failOnFlag, err) } - r := reporter.NewConsoleReporter(os.Stderr, minSeverity) + var r reporter.Reporter + if c.Bool(teamCityFlag) { + r = reporter.NewTeamCityReporter(os.Stderr) + } else { + r = reporter.NewConsoleReporter(os.Stderr, minSeverity) + } + err = r.Submit(summary) if err != nil { return err diff --git a/cmd/pint/scan.go b/cmd/pint/scan.go index 61a3f009..cbeecf41 100644 --- a/cmd/pint/scan.go +++ b/cmd/pint/scan.go @@ -147,6 +147,7 @@ func checkRules(ctx context.Context, workers int, gen *config.PrometheusGenerato for result := range results { summary.Report(result) } + summary.SortReports() summary.Duration = time.Since(start) summary.Entries = len(entries) summary.OnlineChecks = onlineChecksCount.Load() diff --git a/cmd/pint/tests/0158_lint_teamcity.txt b/cmd/pint/tests/0158_lint_teamcity.txt new file mode 100644 index 00000000..fc015b15 --- /dev/null +++ b/cmd/pint/tests/0158_lint_teamcity.txt @@ -0,0 +1,31 @@ +env NO_COLOR=1 +pint.error --no-color lint --min-severity=info --teamcity rules +! stdout . +cmp stderr stderr.txt + +-- stderr.txt -- +level=INFO msg="Finding all rules to check" paths=["rules"] +##teamcity[testSuiteStarted name='alerts/comparison'] +##teamcity[testSuiteStarted name='Warning'] +##teamcity[testStarted name='rules/0001.yml:5'] +##teamcity[testStdErr name='rules/0001.yml:5' out='alert query doesn|'t have any condition, it will always fire if the metric exists'] +##teamcity[testFinished name='rules/0001.yml:5'] +##teamcity[testSuiteFinished name='Warning'] +##teamcity[testSuiteFinished name='alerts/comparison'] +##teamcity[testSuiteStarted name='promql/syntax'] +##teamcity[testSuiteStarted name='Fatal'] +##teamcity[testStarted name='rules/0001.yml:7'] +##teamcity[testFailed name='rules/0001.yml:7' message='' details='syntax error: unexpected identifier "with"'] +##teamcity[testFinished name='rules/0001.yml:7'] +##teamcity[testSuiteFinished name='Fatal'] +##teamcity[testSuiteFinished name='promql/syntax'] +level=INFO msg="Problems found" Fatal=1 Warning=1 +level=ERROR msg="Fatal error" err="found 1 problem(s) with severity Bug or higher" +-- rules/0001.yml -- +groups: +- name: test + rules: + - alert: Example + expr: up + - alert: Example + expr: sum(xxx) with() diff --git a/cmd/pint/tests/0159_ci_teamcity.txt b/cmd/pint/tests/0159_ci_teamcity.txt new file mode 100644 index 00000000..d5eef7cc --- /dev/null +++ b/cmd/pint/tests/0159_ci_teamcity.txt @@ -0,0 +1,64 @@ +mkdir testrepo +cd testrepo +exec git init --initial-branch=main . + +cp ../src/.pint.hcl . +env GIT_AUTHOR_NAME=pint +env GIT_AUTHOR_EMAIL=pint@example.com +env GIT_COMMITTER_NAME=pint +env GIT_COMMITTER_EMAIL=pint@example.com +exec git add . +exec git commit -am 'import rules and config' + +exec git checkout -b v1 +cp ../src/a.yml a.yml +exec git add a.yml +exec git commit -am 'v1' + +exec git checkout -b v2 +cp ../src/b.yml b.yml +exec git add b.yml +exec git commit -am 'v2' + +exec git checkout -b v3 +exec git rm a.yml +exec git commit -am 'v3' + +pint.error --no-color ci -t +! stdout . +cmp stderr ../stderr.txt + +-- stderr.txt -- +level=INFO msg="Loading configuration file" path=.pint.hcl +level=INFO msg="Finding all rules to check on current git branch using git blame" base=main +level=INFO msg="Problems found" Fatal=1 Warning=1 +##teamcity[testSuiteStarted name='promql/syntax'] +##teamcity[testSuiteStarted name='Fatal'] +##teamcity[testStarted name='b.yml:2'] +##teamcity[testFailed name='b.yml:2' message='' details='syntax error: unexpected identifier "bi"'] +##teamcity[testFinished name='b.yml:2'] +##teamcity[testSuiteFinished name='Fatal'] +##teamcity[testSuiteFinished name='promql/syntax'] +##teamcity[testSuiteStarted name='alerts/comparison'] +##teamcity[testSuiteStarted name='Warning'] +##teamcity[testStarted name='b.yml:4'] +##teamcity[testStdErr name='b.yml:4' out='alert query doesn|'t have any condition, it will always fire if the metric exists'] +##teamcity[testFinished name='b.yml:4'] +##teamcity[testSuiteFinished name='Warning'] +##teamcity[testSuiteFinished name='alerts/comparison'] +level=ERROR msg="Fatal error" err="problems found" +-- src/a.yml -- +- record: rule1 + expr: sum(foo) bi() +-- src/b.yml -- +- record: rule1 + expr: sum(foo) bi() +- alert: rule2 + expr: sum(foo) +-- src/.pint.hcl -- +ci { + baseBranch = "main" +} +parser { + relaxed = [".*"] +} diff --git a/docs/changelog.md b/docs/changelog.md index 24b61ffe..417ef07b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,8 @@ ### Added - Added [alerts/external_labels](checks/alerts/external_labels.md) check. +- Added support for reporting problems to TeamCity using [Service Messages](https://www.jetbrains.com/help/teamcity/service-messages.html). + To enable run it run `pint --teamcity lint` or `pint --teamcity ci`. ### Changed diff --git a/internal/reporter/reporter.go b/internal/reporter/reporter.go index ce1d066e..5eb5b07f 100644 --- a/internal/reporter/reporter.go +++ b/internal/reporter/reporter.go @@ -1,6 +1,7 @@ package reporter import ( + "sort" "time" "golang.org/x/exp/slices" @@ -75,6 +76,24 @@ func (s Summary) hasReport(r Report) bool { return false } +func (s *Summary) SortReports() { + sort.SliceStable(s.reports, func(i, j int) bool { + if s.reports[i].ReportedPath != s.reports[j].ReportedPath { + return s.reports[i].ReportedPath < s.reports[j].ReportedPath + } + if s.reports[i].SourcePath != s.reports[j].SourcePath { + return s.reports[i].SourcePath < s.reports[j].SourcePath + } + if s.reports[i].Problem.Lines[0] != s.reports[j].Problem.Lines[0] { + return s.reports[i].Problem.Lines[0] < s.reports[j].Problem.Lines[0] + } + if s.reports[i].Problem.Reporter != s.reports[j].Problem.Reporter { + return s.reports[i].Problem.Reporter < s.reports[j].Problem.Reporter + } + return s.reports[i].Problem.Text < s.reports[j].Problem.Text + }) +} + func (s Summary) Reports() (reports []Report) { return s.reports } diff --git a/internal/reporter/teamcity.go b/internal/reporter/teamcity.go new file mode 100644 index 00000000..2b286d28 --- /dev/null +++ b/internal/reporter/teamcity.go @@ -0,0 +1,87 @@ +package reporter + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/cloudflare/pint/internal/checks" +) + +func NewTeamCityReporter(output io.Writer) TeamCityReporter { + return TeamCityReporter{ + output: output, + escaper: strings.NewReplacer( + "'", "|'", + "\n", "|n", + "\r", "|r", + "\\uNNNN", "|0xNNNN", + "|", "||", + "[", "|[", + "]", "|]", + ), + } +} + +type TeamCityReporter struct { + output io.Writer + escaper *strings.Replacer +} + +func (tc TeamCityReporter) name(report Report) string { + return fmt.Sprintf("%s:%d", report.ReportedPath, report.Problem.Lines[0]) +} + +func (tc TeamCityReporter) escape(s string) string { + return tc.escaper.Replace(s) +} + +func (tc TeamCityReporter) Submit(summary Summary) error { + var buf strings.Builder + for _, report := range summary.reports { + buf.WriteString("##teamcity[testSuiteStarted name='") + buf.WriteString(report.Problem.Reporter) + buf.WriteString("']\n") + + buf.WriteString("##teamcity[testSuiteStarted name='") + buf.WriteString(report.Problem.Severity.String()) + buf.WriteString("']\n") + + buf.WriteString("##teamcity[testStarted name='") + buf.WriteString(tc.name(report)) + buf.WriteString("']\n") + + if report.Problem.Severity >= checks.Bug { + buf.WriteString("##teamcity[testFailed name='") + buf.WriteString(tc.name(report)) + buf.WriteString("' message='' details='") + buf.WriteString(tc.escape(report.Problem.Text)) + buf.WriteString("']\n") + } else { + buf.WriteString("##teamcity[testStdErr name='") + buf.WriteString(tc.name(report)) + buf.WriteString("' out='") + buf.WriteString(tc.escape(report.Problem.Text)) + buf.WriteString("']\n") + } + + buf.WriteString("##teamcity[testFinished name='") + buf.WriteString(report.ReportedPath) + buf.WriteRune(':') + buf.WriteString(strconv.Itoa(report.Problem.Lines[0])) + buf.WriteString("']\n") + + buf.WriteString("##teamcity[testSuiteFinished name='") + buf.WriteString(report.Problem.Severity.String()) + buf.WriteString("']\n") + + buf.WriteString("##teamcity[testSuiteFinished name='") + buf.WriteString(report.Problem.Reporter) + buf.WriteString("']\n") + + fmt.Fprint(tc.output, buf.String()) + buf.Reset() + } + return nil +} diff --git a/internal/reporter/teamcity_test.go b/internal/reporter/teamcity_test.go new file mode 100644 index 00000000..fd5d7b7f --- /dev/null +++ b/internal/reporter/teamcity_test.go @@ -0,0 +1,135 @@ +package reporter_test + +import ( + "bytes" + "log/slog" + "testing" + + "github.com/neilotoole/slogt" + "github.com/stretchr/testify/require" + + "github.com/cloudflare/pint/internal/checks" + "github.com/cloudflare/pint/internal/parser" + "github.com/cloudflare/pint/internal/reporter" +) + +func TestTeamCityReporter(t *testing.T) { + type testCaseT struct { + description string + summary reporter.Summary + output string + err string + } + + p := parser.NewParser() + mockRules, _ := p.Parse([]byte(` +- record: target is down + expr: up == 0 +`)) + + testCases := []testCaseT{ + { + description: "no reports", + summary: reporter.Summary{}, + output: "", + }, + { + description: "info report", + summary: reporter.NewSummary([]reporter.Report{ + { + ReportedPath: "foo.txt", + SourcePath: "foo.txt", + ModifiedLines: []int{2, 4, 5}, + Rule: mockRules[0], + Problem: checks.Problem{ + Fragment: "up", + Lines: []int{5, 6}, + Reporter: "mock", + Text: "mock text", + Severity: checks.Information, + }, + }, + }), + output: `##teamcity[testSuiteStarted name='mock'] +##teamcity[testSuiteStarted name='Information'] +##teamcity[testStarted name='foo.txt:5'] +##teamcity[testStdErr name='foo.txt:5' out='mock text'] +##teamcity[testFinished name='foo.txt:5'] +##teamcity[testSuiteFinished name='Information'] +##teamcity[testSuiteFinished name='mock'] +`, + }, + { + description: "bug report", + summary: reporter.NewSummary([]reporter.Report{ + { + ReportedPath: "foo.txt", + SourcePath: "foo.txt", + ModifiedLines: []int{2, 4, 5}, + Rule: mockRules[0], + Problem: checks.Problem{ + Fragment: "up", + Lines: []int{5, 6}, + Reporter: "mock", + Text: "mock text", + Severity: checks.Bug, + }, + }, + }), + output: `##teamcity[testSuiteStarted name='mock'] +##teamcity[testSuiteStarted name='Bug'] +##teamcity[testStarted name='foo.txt:5'] +##teamcity[testFailed name='foo.txt:5' message='' details='mock text'] +##teamcity[testFinished name='foo.txt:5'] +##teamcity[testSuiteFinished name='Bug'] +##teamcity[testSuiteFinished name='mock'] +`, + }, + { + description: "escaping characters", + summary: reporter.NewSummary([]reporter.Report{ + { + ReportedPath: "foo.txt", + SourcePath: "foo.txt", + ModifiedLines: []int{2, 4, 5}, + Rule: mockRules[0], + Problem: checks.Problem{ + Fragment: "up", + Lines: []int{5, 6}, + Reporter: "mock", + Text: `mock text +with [new lines] and pipe| chars that are 'quoted' +`, + Severity: checks.Bug, + }, + }, + }), + output: `##teamcity[testSuiteStarted name='mock'] +##teamcity[testSuiteStarted name='Bug'] +##teamcity[testStarted name='foo.txt:5'] +##teamcity[testFailed name='foo.txt:5' message='' details='mock text|nwith |[new lines|] and pipe|| chars that are |'quoted|'|n'] +##teamcity[testFinished name='foo.txt:5'] +##teamcity[testSuiteFinished name='Bug'] +##teamcity[testSuiteFinished name='mock'] +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + slog.SetDefault(slogt.New(t)) + + out := bytes.NewBuffer(nil) + + reporter := reporter.NewTeamCityReporter(out) + err := reporter.Submit(tc.summary) + + if tc.err != "" { + require.EqualError(t, err, tc.err) + } else { + require.NoError(t, err) + require.Equal(t, tc.output, out.String()) + } + }) + } +}