Repository: googleapis/api-linter
Pull request #999 (2022-08-18 23:02:37)

feat: add GitHub error output format option
In this mode, the linter runs and prints out to stdout following the
GitHub "::error" annotation to attach an error to a particular file on a
particular line.

GitHub's documentation on the matter.
https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message

conventional-commit-lint-gcf[bot]: <!-- probot comment [1356997]-->
🤖 I detect that the PR title and the commit message differ and there's only one commit. To use the PR title for the commit history, you can use Github's automerge feature with squashing, or use `automerge` label. Good luck human!

 -- conventional-commit-lint bot
https://conventionalcommits.org/
noahdietz: Ignore the conventional commit check, I fixed your PR title already and subsequent commits means the title will be the squashed message.

However looks like the `lint` job wants your to run `goimports` on the added code.
achew22: Fixed the lint error with https://github.com/googleapis/api-linter/pull/1000. Sorry to grab such a prominent PR number, but congratulations on hitting a milestone!
noahdietz: @achew22 do you have an example of how you are running this in GH Actions? Might be nice to have in our docs, short of developing an actual Action for it (but who has time for fun things like that?)

Patch:
diff --git a/cmd/api-linter/cli.go b/cmd/api-linter/cli.go
index d6557fb..0cfc91f 100644
--- a/cmd/api-linter/cli.go
+++ b/cmd/api-linter/cli.go
@@ -64,7 +64,7 @@ func newCli(args []string) *cli {
 	// Register flag variables.
 	fs := pflag.NewFlagSet("api-linter", pflag.ExitOnError)
 	fs.StringVar(&cfgFlag, "config", "", "The linter config file.")
-	fs.StringVar(&fmtFlag, "output-format", "", "The format of the linting results.\nSupported formats include \"yaml\", \"json\" and \"summary\" table.\nYAML is the default.")
+	fs.StringVar(&fmtFlag, "output-format", "", "The format of the linting results.\nSupported formats include \"yaml\", \"json\",\"github\" and \"summary\" table.\nYAML is the default.")
 	fs.StringVarP(&outFlag, "output-path", "o", "", "The output file path.\nIf not given, the linting results will be printed out to STDOUT.")
 	fs.BoolVar(&setExitStatusOnLintFailure, "set-exit-status", false, "Return exit status 1 when lint errors are found.")
 	fs.BoolVar(&versionFlag, "version", false, "Print version and exit.")
@@ -252,6 +252,14 @@ var outputFormatFuncs = map[string]formatFunc{
 	"yaml": yaml.Marshal,
 	"yml":  yaml.Marshal,
 	"json": json.Marshal,
+	"github": func(i interface{}) ([]byte, error) {
+		switch v := i.(type) {
+		case []lint.Response:
+			return formatGitHubActionOutput(v), nil
+		default:
+			return json.Marshal(v)
+		}
+	},
 	"summary": func(i interface{}) ([]byte, error) {
 		switch v := i.(type) {
 		case []lint.Response:
diff --git a/cmd/api-linter/github_actions.go b/cmd/api-linter/github_actions.go
new file mode 100644
index 0000000..2edaf1a
--- /dev/null
+++ b/cmd/api-linter/github_actions.go
@@ -0,0 +1,53 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+
+	"github.com/googleapis/api-linter/lint"
+)
+
+// formatGitHubActionOutput returns lint errors in GitHub actions format.
+func formatGitHubActionOutput(responses []lint.Response) []byte {
+	var buf bytes.Buffer
+	for _, response := range responses {
+		for _, problem := range response.Problems {
+			// lint example:
+			// ::error file={name},line={line},endLine={endLine},title={title}::{message}
+			// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message
+
+			fmt.Fprintf(&buf, "::error file=%s", response.FilePath)
+			if problem.Location != nil {
+				fmt.Fprintf(&buf, " line=%d", problem.Location.Span[0])
+				fmt.Fprintf(&buf, " col=%d", problem.Location.Span[1])
+				fmt.Fprintf(&buf, " endLine=%d", problem.Location.Span[2])
+				fmt.Fprintf(&buf, " endColumn=%d", problem.Location.Span[3])
+			}
+
+			// GitHub uses :: as control characters (which are also used to delimit
+			// linter rules. In order to prevent confusion, replace the double colon
+			// with two Armenian full stops which are indistinguishable to my eye.
+			runeThatLooksLikeTwoColonsButIsActuallyTwoArmenianFullStops := "։։"
+			title := strings.ReplaceAll(string(problem.RuleID), "::", runeThatLooksLikeTwoColonsButIsActuallyTwoArmenianFullStops)
+			message := strings.ReplaceAll(problem.Message, "\n", "\\n")
+			fmt.Fprintf(&buf, " title=%s::%s\n", title, message)
+		}
+	}
+
+	return buf.Bytes()
+}


Test patch:
diff --git a/cmd/api-linter/github_actions_test.go b/cmd/api-linter/github_actions_test.go
new file mode 100644
index 0000000..6d6206e
--- /dev/null
+++ b/cmd/api-linter/github_actions_test.go
@@ -0,0 +1,109 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/googleapis/api-linter/lint"
+	"google.golang.org/protobuf/types/descriptorpb"
+)
+
+func TestFormatGitHubActionOutput(t *testing.T) {
+	tests := []struct {
+		name string
+		data []lint.Response
+		want string
+	}{
+		{
+			name: "Empty input",
+			data: []lint.Response{},
+			want: "",
+		},
+		{
+			name: "Example with location specifics",
+			data: []lint.Response{
+				{
+					FilePath: "example.proto",
+					Problems: []lint.Problem{
+						{
+							RuleID: "core::naming_formats::field_names",
+							Location: &descriptorpb.SourceCodeInfo_Location{
+								Span: []int32{1, 2, 3, 4},
+							},
+						},
+						{
+							RuleID:  "core::naming_formats::field_names",
+							Message: "multi\nline\ncomment",
+							Location: &descriptorpb.SourceCodeInfo_Location{
+								Span: []int32{5, 6, 7, 8},
+							},
+						},
+					},
+				},
+			},
+			want: `::error file=example.proto line=1 col=2 endLine=3 endColumn=4 title=core։։naming_formats։։field_names::
+::error file=example.proto line=5 col=6 endLine=7 endColumn=8 title=core։։naming_formats։։field_names::multi\nline\ncomment
+`,
+		},
+		{
+			name: "Example with a couple of responses",
+			data: []lint.Response{
+				{
+					FilePath: "example.proto",
+					Problems: []lint.Problem{
+						{RuleID: "core::naming_formats::field_names"},
+						{RuleID: "core::naming_formats::field_names"},
+					},
+				},
+				{
+					FilePath: "example2.proto",
+					Problems: []lint.Problem{
+						{RuleID: "core::0131::request_message::name"},
+						{RuleID: "core::0132::response_message::name"},
+					},
+				},
+				{
+					FilePath: "example3.proto",
+					Problems: []lint.Problem{
+						{RuleID: "core::naming_formats::field_names"},
+					},
+				},
+				{
+					FilePath: "example4.proto",
+					Problems: []lint.Problem{
+						{RuleID: "core::naming_formats::field_names"},
+						{RuleID: "core::0132::response_message::name"},
+					},
+				},
+			},
+			want: `::error file=example.proto title=core։։naming_formats։։field_names::
+::error file=example.proto title=core։։naming_formats։։field_names::
+::error file=example2.proto title=core։։0131։։request_message։։name::
+::error file=example2.proto title=core։։0132։։response_message։։name::
+::error file=example3.proto title=core։։naming_formats։։field_names::
+::error file=example4.proto title=core։։naming_formats։։field_names::
+::error file=example4.proto title=core։։0132։։response_message։։name::
+`,
+		},
+	}
+	for _, test := range tests {
+		got := formatGitHubActionOutput(test.data)
+		if diff := cmp.Diff(string(test.want), string(got)); diff != "" {
+			t.Errorf("formatGitHubActionOutput() mismatch (-want +got):\n%s", diff)
+		}
+	}
+}