1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "fmt"
7 "slices"
8 "unicode/utf8"
9)
10
11// AnnotationLevel is the severity of an annotation, with the names of GitHub's
12// check run annotation levels.
13type AnnotationLevel string
14
15// Annotation levels.
16const (
17 AnnotationNotice AnnotationLevel = "notice"
18 AnnotationWarning AnnotationLevel = "warning"
19 AnnotationFailure AnnotationLevel = "failure"
20)
21
22// Valid reports whether the level is one of the three.
23func (l AnnotationLevel) Valid() bool {
24 return l == AnnotationNotice || l == AnnotationWarning || l == AnnotationFailure
25}
26
27// Limits of annotations and summaries (runner.proto, run.proto). Lengths count
28// characters (code points), not bytes, except the summary size.
29const (
30 // CIMaxAnnotationsPerLevel bounds the annotations of one level in one step.
31 CIMaxAnnotationsPerLevel = 10
32 // CIMaxAnnotationMessage, CIMaxAnnotationTitle and CIMaxAnnotationFile bound
33 // the text fields of an annotation, in characters.
34 CIMaxAnnotationMessage = 4096
35 CIMaxAnnotationTitle = 4096
36 CIMaxAnnotationFile = 1024
37 // CIMaxListedAnnotations is the most annotations ListAnnotations returns.
38 CIMaxListedAnnotations = 1000
39 // CIMaxSummaryBytes bounds the summary of one step (1 MiB).
40 CIMaxSummaryBytes = 1 << 20
41 // CIMaxListedSummaries is the most summaries GetStepSummaries returns for a
42 // job, as GitHub does.
43 CIMaxListedSummaries = 20
44)
45
46// CIAnnotation is a message attached to a step, and optionally to a place in a
47// file, like an annotation of a GitHub check run.
48type CIAnnotation struct {
49 Level AnnotationLevel
50 Title, Message, File string
51 StartLine, EndLine, StartColumn, EndColumn int32
52 // Step is the number of the step that produced the annotation.
53 Step int32
54}
55
56// CIStepSummary is the job summary (Markdown) of one step.
57type CIStepSummary struct {
58 Step int32
59 Markdown string
60 // Omitted is set when the summary exists but was not read, because the
61 // caller's byte budget was used up; Markdown is then empty.
62 Omitted bool
63}
64
65// CheckAnnotations validates the annotations of a request: the limits of the
66// contract, whole or not at all.
67func CheckAnnotations(anns []CIAnnotation) error {
68 type key struct {
69 step int32
70 level AnnotationLevel
71 }
72 count := map[key]int{}
73 for i, a := range anns {
74 switch {
75 case !a.Level.Valid():
76 return fmt.Errorf("%w: annotation %d has no level", ErrInvalid, i)
77 case a.Step < 1 || a.Step > CIMaxSteps:
78 return fmt.Errorf("%w: annotation %d: step number %d is not between 1 and %d", ErrInvalid, i, a.Step, CIMaxSteps)
79 case a.Message == "":
80 return fmt.Errorf("%w: annotation %d has no message", ErrInvalid, i)
81 case utf8.RuneCountInString(a.Message) > CIMaxAnnotationMessage:
82 return fmt.Errorf("%w: annotation %d: the message is longer than %d characters", ErrInvalid, i, CIMaxAnnotationMessage)
83 case utf8.RuneCountInString(a.Title) > CIMaxAnnotationTitle:
84 return fmt.Errorf("%w: annotation %d: the title is longer than %d characters", ErrInvalid, i, CIMaxAnnotationTitle)
85 case utf8.RuneCountInString(a.File) > CIMaxAnnotationFile:
86 return fmt.Errorf("%w: annotation %d: the file is longer than %d characters", ErrInvalid, i, CIMaxAnnotationFile)
87 case !utf8.ValidString(a.Message) || !utf8.ValidString(a.Title) || !utf8.ValidString(a.File):
88 return fmt.Errorf("%w: annotation %d is not valid UTF-8", ErrInvalid, i)
89 case a.StartLine < 0 || a.EndLine < 0 || a.StartColumn < 0 || a.EndColumn < 0:
90 return fmt.Errorf("%w: annotation %d: lines and columns are not negative", ErrInvalid, i)
91 case a.EndLine != 0 && a.EndLine < a.StartLine:
92 return fmt.Errorf("%w: annotation %d: end_line is before start_line", ErrInvalid, i)
93 }
94 k := key{a.Step, a.Level}
95 if count[k]++; count[k] > CIMaxAnnotationsPerLevel {
96 return fmt.Errorf("%w: step %d has more than %d annotations of level %s", ErrInvalid, a.Step, CIMaxAnnotationsPerLevel, a.Level)
97 }
98 }
99 return nil
100}
101
102// GroupAnnotations groups annotations by step number, each group in the order
103// of the input, and returns the step numbers in ascending order.
104func GroupAnnotations(anns []CIAnnotation) (map[int32][]CIAnnotation, []int32) {
105 by := map[int32][]CIAnnotation{}
106 var steps []int32
107 for _, a := range anns {
108 if _, ok := by[a.Step]; !ok {
109 steps = append(steps, a.Step)
110 }
111 by[a.Step] = append(by[a.Step], a)
112 }
113 slices.Sort(steps)
114 return by, steps
115}