Repository navigation
Expand file tree
/
Copy pathformat_githubactions.go
More file actions
178 lines (152 loc) · 6.16 KB
/
Copy pathformat_githubactions.go
File metadata and controls
178 lines (152 loc) · 6.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
package formatters
import (
"bytes"
"fmt"
"os"
"strconv"
"strings"
"github.com/oasdiff/oasdiff/checker"
)
var githubActionsSeverity = map[checker.Level]string{
checker.ERR: "error",
checker.WARN: "warning",
checker.INFO: "notice",
}
type GitHubActionsFormatter struct {
notImplementedFormatter
Localizer checker.Localizer
}
func newGitHubActionsFormatter(l checker.Localizer) GitHubActionsFormatter {
return GitHubActionsFormatter{
Localizer: l,
}
}
func (f GitHubActionsFormatter) RenderChangelog(changes checker.Changes, opts RenderOpts) ([]byte, error) {
var buf bytes.Buffer
// add error, warning and notice count to job output parameters
err := writeGitHubActionsJobOutputParameters(map[string]string{
"error_count": fmt.Sprint(changes.GetLevelCount()[checker.ERR]),
"warning_count": fmt.Sprint(changes.GetLevelCount()[checker.WARN]),
"info_count": fmt.Sprint(changes.GetLevelCount()[checker.INFO]),
})
if err != nil {
return nil, err
}
// generate messages for each change (source file, line and column are optional)
for _, change := range changes {
var params = []string{
"title=" + escapeProperty(change.GetId()),
}
if rev := change.GetRevisionSource(); rev != nil && rev.File != "" && !isHTTPSource(rev.File) {
params = append(params, "file="+escapeProperty(rev.File))
if rev.Line != 0 {
params = append(params, "line="+strconv.Itoa(rev.Line))
}
if rev.Column != 0 {
params = append(params, "col="+strconv.Itoa(rev.Column))
}
} else if sourceFile := change.GetSourceFile(); sourceFile != "" {
// Fallback: operation source file when no revision source is available
params = append(params, "file="+escapeProperty(sourceFile))
}
fmt.Fprintf(&buf, "::%s %s::%s\n", githubActionsSeverity[change.GetLevel()], strings.Join(params, ","), getMessage(change, f.Localizer))
}
return buf.Bytes(), nil
}
func getMessage(change checker.Change, l checker.Localizer) string {
return escapeData(fmt.Sprintf("in API %s %s %s", change.GetOperation(), change.GetPath(), change.GetUncolorizedText(l)))
}
// escapeData escapes a string for use as a GitHub Actions annotation
// message, per the workflow-command rules. % is escaped first so the
// %0D/%0A sequences it introduces are not themselves double-escaped.
func escapeData(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
s = strings.ReplaceAll(s, "\r", "%0D")
s = strings.ReplaceAll(s, "\n", "%0A")
return s
}
// escapeProperty escapes a string for use as a GitHub Actions annotation
// property value (title, file): escapeData plus the property delimiters
// ':' and ',' (e.g. a git-ref source like "main:openapi.yaml").
func escapeProperty(s string) string {
s = escapeData(s)
s = strings.ReplaceAll(s, ":", "%3A")
s = strings.ReplaceAll(s, ",", "%2C")
return s
}
// RenderValidate emits one GitHub Actions annotation per finding so the
// oasdiff-action validate wrapper can surface violations inline on the
// PR's Files Changed tab, and publishes per-severity counts as step
// outputs. Findings carry exact file/line/column, which the annotation
// uses to anchor itself.
func (f GitHubActionsFormatter) RenderValidate(findings Findings, opts RenderOpts) ([]byte, error) {
var buf bytes.Buffer
count := findings.GetLevelCount()
err := writeGitHubActionsJobOutputParameters(map[string]string{
"error_count": fmt.Sprint(count[checker.ERR]),
"warning_count": fmt.Sprint(count[checker.WARN]),
"info_count": fmt.Sprint(count[checker.INFO]),
})
if err != nil {
return nil, err
}
for _, finding := range findings {
params := []string{"title=" + escapeProperty(finding.Id)}
if finding.Source.File != "" && !isHTTPSource(finding.Source.File) {
params = append(params, "file="+escapeProperty(finding.Source.File))
if finding.Source.Line != 0 {
params = append(params, "line="+strconv.Itoa(finding.Source.Line))
}
if finding.Source.Column != 0 {
params = append(params, "col="+strconv.Itoa(finding.Source.Column))
}
}
fmt.Fprintf(&buf, "::%s %s::%s\n", githubActionsSeverity[finding.Level], strings.Join(params, ","), getFindingMessage(finding))
}
return buf.Bytes(), nil
}
// getFindingMessage renders a finding's annotation body. The "in API
// METHOD /path" prefix is added only when the finding has operation
// context; doc-root and components-rooted findings (the majority) have
// none, so prefixing would emit a stray "in API ".
func getFindingMessage(finding Finding) string {
message := finding.Text
if finding.Operation != "" && finding.Path != "" {
message = fmt.Sprintf("in API %s %s %s", finding.Operation, finding.Path, message)
}
return escapeData(message)
}
func (f GitHubActionsFormatter) SupportedOutputs() []Output {
return []Output{OutputChangelog, OutputValidate}
}
func isHTTPSource(file string) bool {
return strings.HasPrefix(file, "http://") || strings.HasPrefix(file, "https://")
}
// writeGitHubActionsJobOutputParameters publishes key=value pairs as GitHub Actions
// step output parameters by appending them to the file referenced by $GITHUB_OUTPUT.
// This is the mechanism GitHub Actions uses to pass outputs between steps
// (https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs).
// If $GITHUB_OUTPUT is unset (i.e. running outside GitHub Actions), this is a no-op.
func writeGitHubActionsJobOutputParameters(params map[string]string) error {
githubOutputFile := os.Getenv("GITHUB_OUTPUT")
if githubOutputFile == "" {
// If GITHUB_OUTPUT is not set, we can't write job output parameters (running outside of GitHub Actions)
return nil
}
// open the file in append mode
file, err := os.OpenFile(githubOutputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open GitHub Actions job output file: %w", err)
}
defer func() { _ = file.Close() }()
// collect all parameters into a string
var contentBuilder strings.Builder
for key, value := range params {
fmt.Fprintf(&contentBuilder, "%s=%s\n", key, value)
}
// write the parameters to the file
if _, err := file.WriteString(contentBuilder.String()); err != nil {
return fmt.Errorf("failed to write GitHub Actions job output parameters: %w", err)
}
return nil
}