Skip to content

Commit e1d63e0

Browse files
committed
[Logs/annotations] add structured CI annotation loggers for GitHub, Azure DevOps, and TeamCity, with adapters for logs.Loggers.
1 parent 88d49fe commit e1d63e0

15 files changed

Lines changed: 838 additions & 2 deletions

changes/202607161700.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: `[Logs/annotations]` add structured CI annotation loggers for GitHub, Azure DevOps, and TeamCity, with adapters for logs.Loggers.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package annotations
2+
3+
import (
4+
"github.com/ARM-software/golang-utils/utils/collection"
5+
"github.com/ARM-software/golang-utils/utils/field"
6+
)
7+
8+
// Severity identifies the severity of an annotation.
9+
type Severity int
10+
11+
//go:generate go tool enumer -type=Severity -text -json -yaml
12+
13+
const (
14+
SeverityError Severity = iota
15+
SeverityWarning
16+
SeverityNotice
17+
)
18+
19+
// Annotation describes a structured annotation message.
20+
type Annotation struct {
21+
// Severity identifies whether the annotation is an error, warning, or notice.
22+
Severity Severity
23+
// Message is the human-readable annotation text.
24+
Message string
25+
// File is the file path associated with the annotation, if any.
26+
File string
27+
// Line is the 1-based line number associated with the annotation, if any.
28+
Line *int
29+
// Column is the 1-based column number associated with the annotation, if any.
30+
Column *int
31+
}
32+
33+
// AnnotationOption configures an annotation.
34+
type AnnotationOption func(*Annotation) *Annotation
35+
36+
// WithFile sets the file path associated with an annotation.
37+
func WithFile(path string) AnnotationOption {
38+
return func(annotation *Annotation) *Annotation {
39+
annotation.File = path
40+
return annotation
41+
}
42+
}
43+
44+
// WithLine sets the line associated with an annotation.
45+
func WithLine(line int) AnnotationOption {
46+
return func(annotation *Annotation) *Annotation {
47+
annotation.Line = field.ToOptionalInt(line)
48+
return annotation
49+
}
50+
}
51+
52+
// WithColumn sets the column associated with an annotation.
53+
func WithColumn(column int) AnnotationOption {
54+
return func(annotation *Annotation) *Annotation {
55+
annotation.Column = field.ToOptionalInt(column)
56+
return annotation
57+
}
58+
}
59+
60+
func newAnnotation(severity Severity, message string, options ...AnnotationOption) Annotation {
61+
annotation := Annotation{Severity: severity, Message: message}
62+
collection.ForEach(options, func(option AnnotationOption) {
63+
if option != nil {
64+
annotation = *option(&annotation)
65+
}
66+
})
67+
return annotation
68+
}

utils/logs/annotations/azure.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package annotations
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/ARM-software/golang-utils/utils/collection"
8+
baselogs "github.com/ARM-software/golang-utils/utils/logs"
9+
"github.com/ARM-software/golang-utils/utils/reflection"
10+
)
11+
12+
// AzureDevOpsFormatter formats annotations as Azure DevOps logging commands.
13+
//
14+
// Reference:
15+
// - https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
16+
type AzureDevOpsFormatter struct{}
17+
18+
// NewAzureDevOpsLogger creates an Azure DevOps-formatted annotation logger backed by baseLogger.
19+
func NewAzureDevOpsLogger(baseLogger baselogs.Loggers) (*AnnotationLogger, error) {
20+
return NewLogger(baseLogger, AzureDevOpsFormatter{})
21+
}
22+
23+
func (AzureDevOpsFormatter) Format(annotation *Annotation) string {
24+
if annotation == nil {
25+
return ""
26+
}
27+
typeName := strings.ToLower(strings.TrimPrefix(annotation.Severity.String(), "Severity"))
28+
if annotation.Severity == SeverityNotice {
29+
typeName = "warning"
30+
}
31+
properties := collection.Filter([]string{
32+
fmt.Sprintf("type=%s", typeName),
33+
func() string {
34+
if reflection.IsEmpty(annotation.File) {
35+
return ""
36+
}
37+
return fmt.Sprintf("sourcepath=%s", escapeAzureProperty(annotation.File))
38+
}(),
39+
func() string {
40+
if annotation.Line == nil {
41+
return ""
42+
}
43+
return fmt.Sprintf("linenumber=%d", *annotation.Line)
44+
}(),
45+
func() string {
46+
if annotation.Column == nil {
47+
return ""
48+
}
49+
return fmt.Sprintf("columnnumber=%d", *annotation.Column)
50+
}(),
51+
}, func(value string) bool {
52+
return reflection.IsNotEmpty(value)
53+
})
54+
return fmt.Sprintf("##vso[task.logissue %s]%s", strings.Join(properties, ";"), escapeAzureMessage(annotation.Message))
55+
}
56+
57+
func escapeAzureMessage(value string) string {
58+
return strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A").Replace(value)
59+
}
60+
61+
func escapeAzureProperty(value string) string {
62+
return strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A", ";", "%3B", "]", "%5D").Replace(value)
63+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package annotations
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestAzureDevOpsFormatter(t *testing.T) {
10+
formatter := AzureDevOpsFormatter{}
11+
line := 8
12+
annotation := &Annotation{Severity: SeverityWarning, Message: "problem", File: "pkg/file.go", Line: &line}
13+
assert.Equal(t,
14+
"##vso[task.logissue type=warning;sourcepath=pkg/file.go;linenumber=8]problem",
15+
formatter.Format(annotation),
16+
)
17+
}

utils/logs/annotations/base.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package annotations
2+
3+
import (
4+
5+
"github.com/ARM-software/golang-utils/utils/commonerrors"
6+
baselogs "github.com/ARM-software/golang-utils/utils/logs"
7+
"github.com/ARM-software/golang-utils/utils/reflection"
8+
)
9+
10+
// AnnotationLogger formats annotations and emits them through an underlying
11+
// logger.
12+
//
13+
// The logger delegates final emission to an existing [logs.Loggers]
14+
// implementation and is therefore suitable for any sink already supported by
15+
// the logs package.
16+
type AnnotationLogger struct {
17+
baselogs.Loggers
18+
formatter IFormatter
19+
}
20+
21+
// NewLogger creates an annotation logger backed by baseLogger.
22+
func NewLogger(baseLogger baselogs.Loggers, formatter IFormatter) (*AnnotationLogger, error) {
23+
logger := &AnnotationLogger{Loggers: baseLogger, formatter: formatter}
24+
if err := logger.Check(); err != nil {
25+
return nil, err
26+
}
27+
return logger, nil
28+
}
29+
30+
func (l *AnnotationLogger) Check() error {
31+
if reflection.IsEmpty(l.Loggers) {
32+
return commonerrors.ErrNoLogger
33+
}
34+
if l.formatter == nil {
35+
return commonerrors.UndefinedVariable("annotation formatter")
36+
}
37+
return l.Loggers.Check()
38+
}
39+
40+
// WriteAnnotation writes annotation using the configured formatter.
41+
func (l *AnnotationLogger) WriteAnnotation(annotation *Annotation) error {
42+
if err := l.Check(); err != nil {
43+
return err
44+
}
45+
if reflection.IsEmpty(annotation) {
46+
return commonerrors.UndefinedVariable("annotation")
47+
}
48+
line := l.formatter.Format(annotation)
49+
switch annotation.Severity {
50+
case SeverityError:
51+
l.Loggers.LogError(line)
52+
default:
53+
l.Loggers.Log(line)
54+
}
55+
return nil
56+
}
57+
58+
// WriteError writes an error-level annotation.
59+
func (l *AnnotationLogger) WriteError(message string, options ...AnnotationOption) error {
60+
annotation := newAnnotation(SeverityError, message, options...)
61+
return l.WriteAnnotation(&annotation)
62+
}
63+
64+
// WriteWarning writes a warning-level annotation.
65+
func (l *AnnotationLogger) WriteWarning(message string, options ...AnnotationOption) error {
66+
annotation := newAnnotation(SeverityWarning, message, options...)
67+
return l.WriteAnnotation(&annotation)
68+
}
69+
70+
// WriteNotice writes a notice-level annotation.
71+
func (l *AnnotationLogger) WriteNotice(message string, options ...AnnotationOption) error {
72+
annotation := newAnnotation(SeverityNotice, message, options...)
73+
return l.WriteAnnotation(&annotation)
74+
}
75+
76+
var _ IAnnotationLogger = (*AnnotationLogger)(nil)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package annotations
2+
3+
import (
4+
"testing"
5+
6+
baselogs "github.com/ARM-software/golang-utils/utils/logs"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestAnnotationLoggerFromLoggers(t *testing.T) {
12+
base, err := baselogs.NewPlainStringLogger()
13+
require.NoError(t, err)
14+
15+
logger, err := NewGitHubLogger(base)
16+
require.NoError(t, err)
17+
line := 3
18+
annotation := newAnnotation(SeverityError, "broken", WithFile("pkg/file.go"), WithLine(line))
19+
require.NoError(t, logger.WriteAnnotation(&annotation))
20+
require.NoError(t, logger.WriteWarning("warn"))
21+
require.NoError(t, logger.WriteNotice("note"))
22+
23+
assert.Equal(t,
24+
"::error file=pkg/file.go,line=3::broken\n::warning::warn\n::notice::note\n",
25+
base.GetLogContent(),
26+
)
27+
}

utils/logs/annotations/doc.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Package annotations provides loggers for emitting structured build and CI
2+
// annotations such as errors, warnings, and notices.
3+
//
4+
// Unlike ordinary log lines, annotations are intended for systems that can
5+
// highlight issues in a richer way, for example by attaching messages to files
6+
// and line numbers or surfacing build problems prominently in the UI.
7+
//
8+
// The package provides a generic annotation model plus platform-specific
9+
// formatters for common CI systems. Annotation loggers are built on top of the
10+
// repository's [logs.Loggers] abstraction so they can reuse existing logging
11+
// sinks.
12+
//
13+
// References:
14+
// - GitHub Actions workflow commands:
15+
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
16+
// - Azure DevOps logging commands:
17+
// https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
18+
// - TeamCity service messages:
19+
// https://www.jetbrains.com/help/teamcity/service-messages.html
20+
package annotations

utils/logs/annotations/github.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package annotations
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/ARM-software/golang-utils/utils/collection"
8+
baselogs "github.com/ARM-software/golang-utils/utils/logs"
9+
"github.com/ARM-software/golang-utils/utils/reflection"
10+
)
11+
12+
// GitHubFormatter formats annotations as GitHub Actions annotation commands.
13+
//
14+
// Reference:
15+
// - https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
16+
type GitHubFormatter struct{}
17+
18+
// NewGitHubLogger creates a GitHub-formatted annotation logger backed by baseLogger.
19+
func NewGitHubLogger(baseLogger baselogs.Loggers) (*AnnotationLogger, error) {
20+
return NewLogger(baseLogger, GitHubFormatter{})
21+
}
22+
23+
func (GitHubFormatter) Format(annotation *Annotation) string {
24+
if annotation == nil {
25+
return ""
26+
}
27+
command := strings.ToLower(strings.TrimPrefix(annotation.Severity.String(), "Severity"))
28+
properties := collection.Filter([]string{
29+
func() string {
30+
if reflection.IsEmpty(annotation.File) {
31+
return ""
32+
}
33+
return fmt.Sprintf("file=%s", escapeGitHubProperty(annotation.File))
34+
}(),
35+
func() string {
36+
if annotation.Line == nil {
37+
return ""
38+
}
39+
return fmt.Sprintf("line=%d", *annotation.Line)
40+
}(),
41+
func() string {
42+
if annotation.Column == nil {
43+
return ""
44+
}
45+
return fmt.Sprintf("col=%d", *annotation.Column)
46+
}(),
47+
}, func(value string) bool {
48+
return reflection.IsNotEmpty(value)
49+
})
50+
propertyBlock := ""
51+
if len(properties) > 0 {
52+
propertyBlock = " " + strings.Join(properties, ",")
53+
}
54+
return fmt.Sprintf("::%s%s::%s", command, propertyBlock, escapeGitHubMessage(annotation.Message))
55+
}
56+
57+
func escapeGitHubMessage(value string) string {
58+
return strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A").Replace(value)
59+
}
60+
61+
func escapeGitHubProperty(value string) string {
62+
return strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A", ":", "%3A", ",", "%2C").Replace(value)
63+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package annotations
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestGitHubFormatter(t *testing.T) {
10+
formatter := GitHubFormatter{}
11+
line := 12
12+
column := 4
13+
annotation := &Annotation{Severity: SeverityError, Message: "bad\nmessage", File: "pkg/file.go", Line: &line, Column: &column}
14+
assert.Equal(t,
15+
"::error file=pkg/file.go,line=12,col=4::bad%0Amessage",
16+
formatter.Format(annotation),
17+
)
18+
}

0 commit comments

Comments
 (0)