Skip to content

Commit 2fc5e8d

Browse files
committed
feat(output): annotate github-actions failures at spec file:line
Signed-off-by: caesarsage <destinyerhabor6@gmail.com>
1 parent ceae5c3 commit 2fc5e8d

5 files changed

Lines changed: 194 additions & 6 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
golang.org/x/oauth2 v0.36.0
1717
golang.org/x/term v0.40.0
1818
gopkg.in/yaml.v2 v2.4.0
19+
gopkg.in/yaml.v3 v3.0.1
1920
microcks.io/testcontainers-go v0.3.3
2021
)
2122

@@ -73,6 +74,5 @@ require (
7374
go.opentelemetry.io/otel/trace v1.41.0 // indirect
7475
golang.org/x/crypto v0.48.0 // indirect
7576
golang.org/x/sys v0.42.0 // indirect
76-
gopkg.in/yaml.v3 v3.0.1 // indirect
7777
microcks.io/go-client v0.3.1 // indirect
7878
)

pkg/output/formatter.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,23 @@ type Formatter interface {
4242
Format(result *connectors.TestResult) (string, error)
4343
}
4444

45+
// Option configures a Formatter.
46+
type Option func(*config)
47+
48+
type config struct {
49+
artifactPath string
50+
}
51+
52+
func WithArtifactPath(path string) Option {
53+
return func(c *config) { c.artifactPath = path }
54+
}
55+
4556
// NewFormatter returns the Formatter for the given format.
46-
func NewFormatter(format OutputFormat) (Formatter, error) {
57+
func NewFormatter(format OutputFormat, opts ...Option) (Formatter, error) {
58+
c := &config{}
59+
for _, o := range opts {
60+
o(c)
61+
}
4762
switch format {
4863
case FormatText:
4964
return &TextFormatter{}, nil
@@ -52,7 +67,7 @@ func NewFormatter(format OutputFormat) (Formatter, error) {
5267
case FormatYAML:
5368
return &YAMLFormatter{}, nil
5469
case FormatGitHubActions:
55-
return &GitHubActionsFormatter{}, nil
70+
return &GitHubActionsFormatter{artifactPath: c.artifactPath}, nil
5671
default:
5772
return nil, fmt.Errorf("unsupported output format %q (use: text, json, yaml, github-actions)", format)
5873
}

pkg/output/github_actions_formatter.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import (
2727
// a collapsible ::group:: per operation, ::error:: annotations for failures
2828
// (and ::notice:: for passes when MICROCKS_ACTIONS_VERBOSE is set), plus a
2929
// markdown table appended to $GITHUB_STEP_SUMMARY.
30-
type GitHubActionsFormatter struct{}
30+
type GitHubActionsFormatter struct {
31+
artifactPath string
32+
}
3133

3234
func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error) {
3335
verbose := os.Getenv("MICROCKS_ACTIONS_VERBOSE") != ""
@@ -42,8 +44,8 @@ func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error
4244
for _, s := range tc.TestStepResults {
4345
switch {
4446
case !s.Success:
45-
fmt.Fprintf(&b, "::error title=%s::%s\n",
46-
escapeProperty(tc.OperationName), escapeData(stepMessage(s)))
47+
fmt.Fprintf(&b, "::error %s::%s\n",
48+
f.errorProperties(tc.OperationName), escapeData(stepMessage(s)))
4749
case verbose:
4850
fmt.Fprintf(&b, "::notice title=%s::%s passed\n",
4951
escapeProperty(tc.OperationName), escapeData(s.RequestName))
@@ -67,6 +69,17 @@ func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error
6769
return b.String(), nil
6870
}
6971

72+
func (f *GitHubActionsFormatter) errorProperties(operationName string) string {
73+
props := []string{"title=" + escapeProperty(operationName)}
74+
if f.artifactPath != "" {
75+
props = append(props, "file="+escapeProperty(f.artifactPath))
76+
if line := operationLine(f.artifactPath, operationName); line > 0 {
77+
props = append(props, fmt.Sprintf("line=%d", line))
78+
}
79+
}
80+
return strings.Join(props, ",")
81+
}
82+
7083
// stepMessage returns the failure message, or a sensible default when empty.
7184
func stepMessage(s connectors.TestStepResult) string {
7285
if strings.TrimSpace(s.Message) != "" {

pkg/output/openapi_linemap.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package output
17+
18+
import (
19+
"os"
20+
"strings"
21+
22+
yamlv3 "gopkg.in/yaml.v3"
23+
)
24+
25+
// operationLine returns the 1-based line of an operation (e.g. "GET /products")
26+
// within an OpenAPI spec file, or 0 if it can't be determined (non-YAML spec,
27+
// parse error, or operation not found). yaml.v3 nodes carry line numbers, which
28+
// yaml.v2 does not expose.
29+
func operationLine(specPath, operationName string) int {
30+
method, path, ok := splitOperation(operationName)
31+
if !ok {
32+
return 0
33+
}
34+
35+
data, err := os.ReadFile(specPath)
36+
if err != nil {
37+
return 0
38+
}
39+
40+
var doc yamlv3.Node
41+
if err := yamlv3.Unmarshal(data, &doc); err != nil {
42+
return 0
43+
}
44+
root := &doc
45+
if root.Kind == yamlv3.DocumentNode && len(root.Content) > 0 {
46+
root = root.Content[0]
47+
}
48+
49+
_, pathsNode := mappingEntry(root, "paths")
50+
if pathsNode == nil {
51+
return 0
52+
}
53+
_, pathNode := mappingEntry(pathsNode, path)
54+
if pathNode == nil {
55+
return 0
56+
}
57+
line, _ := mappingEntry(pathNode, method)
58+
return line
59+
}
60+
61+
// splitOperation parses "GET /products" into ("get", "/products", true).
62+
func splitOperation(operationName string) (method, path string, ok bool) {
63+
parts := strings.SplitN(strings.TrimSpace(operationName), " ", 2)
64+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
65+
return "", "", false
66+
}
67+
return strings.ToLower(parts[0]), parts[1], true
68+
}
69+
70+
// mappingEntry looks up key in a mapping node and returns the key node's line
71+
// (where "key:" appears) and its value node.
72+
func mappingEntry(node *yamlv3.Node, key string) (int, *yamlv3.Node) {
73+
if node == nil || node.Kind != yamlv3.MappingNode {
74+
return 0, nil
75+
}
76+
for i := 0; i+1 < len(node.Content); i += 2 {
77+
if node.Content[i].Value == key {
78+
return node.Content[i].Line, node.Content[i+1]
79+
}
80+
}
81+
return 0, nil
82+
}

pkg/output/output_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,81 @@ func TestEscaping(t *testing.T) {
153153
t.Errorf("escapeProperty = %q", got)
154154
}
155155
}
156+
157+
const specFixture = `openapi: 3.0.0
158+
info:
159+
title: X
160+
version: 1.0.0
161+
paths:
162+
/products:
163+
get:
164+
operationId: getProducts
165+
responses:
166+
"200":
167+
description: ok
168+
/orders:
169+
post:
170+
operationId: placeOrder
171+
responses:
172+
"201":
173+
description: created
174+
`
175+
176+
func writeSpec(t *testing.T) string {
177+
t.Helper()
178+
p := filepath.Join(t.TempDir(), "spec.yaml")
179+
if err := os.WriteFile(p, []byte(specFixture), 0o644); err != nil {
180+
t.Fatal(err)
181+
}
182+
return p
183+
}
184+
185+
func TestOperationLine(t *testing.T) {
186+
spec := writeSpec(t)
187+
cases := map[string]int{
188+
"GET /products": 7, // line of "get:" under /products
189+
"POST /orders": 13, // line of "post:" under /orders
190+
"GET /nonexistent": 0,
191+
"weird": 0, // no method/path split
192+
}
193+
for op, want := range cases {
194+
if got := operationLine(spec, op); got != want {
195+
t.Errorf("operationLine(%q) = %d, want %d", op, got, want)
196+
}
197+
}
198+
if got := operationLine("/no/such/file.yaml", "GET /products"); got != 0 {
199+
t.Errorf("missing file = %d, want 0", got)
200+
}
201+
}
202+
203+
func TestGitHubActionsFileLineAnnotation(t *testing.T) {
204+
spec := writeSpec(t)
205+
result := &connectors.TestResult{
206+
Success: false,
207+
TestCaseResults: []connectors.TestCaseResult{
208+
{Success: false, OperationName: "GET /products", TestStepResults: []connectors.TestStepResult{
209+
{Success: false, RequestName: "r", Message: "boom"},
210+
}},
211+
},
212+
}
213+
formatter, err := NewFormatter(FormatGitHubActions, WithArtifactPath(spec))
214+
if err != nil {
215+
t.Fatal(err)
216+
}
217+
out, err := formatter.Format(result)
218+
if err != nil {
219+
t.Fatal(err)
220+
}
221+
for _, want := range []string{"title=GET /products", "file=" + spec, "line=7"} {
222+
if !strings.Contains(out, want) {
223+
t.Errorf("annotation missing %q\n%s", want, out)
224+
}
225+
}
226+
227+
// Without an artifact path, no file/line properties.
228+
plain, _ := NewFormatter(FormatGitHubActions)
229+
out2, _ := plain.Format(result)
230+
if strings.Contains(out2, "file=") || strings.Contains(out2, "line=") {
231+
t.Errorf("did not expect file/line without artifact path:\n%s", out2)
232+
}
233+
}

0 commit comments

Comments
 (0)