detached dd8597a

Merge pull request 'feat(ci): store and show annotations, step summaries and step outcomes' (#23) from feat/ci2-results into main

commit
dd8597a66fef3989e78722f192f7e4d01a17df5f
author
bnema · · 2026-10-13 13:01 UTC
merge of
52acaf4 + 3bc6931 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.9.0
43 files · +5369 -566
M README.md +1 -1
@@ -126,7 +126,7 @@ The Actions that run here call a small part of GitHub's REST API, served at `git
126126
127127 Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
128128
129-`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
129+`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`, and the job summaries (the Markdown a step writes to `GITHUB_STEP_SUMMARY`) under `$ERMITE_DATA_DIR/ci/summaries`; both go with their run (retention, repository deletion). Annotations (`::notice`, `::warning`, `::error`) are kept in the metadata store. The run page shows them in an Annotations box and the job page at their step; the summaries are rendered under the run with the README renderer (raw HTML dropped, `javascript:` links dropped). `ermite run view` prints an ANNOTATIONS section, and `ermite run view --summary` prints the summaries as Markdown.
130130
131131 ## Web sign-in
132132
M gen/ermite/storage/ci.pb.go +291 -54 generated file: collapsed
A internal/adapters/cisummaries/cisummaries.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package cisummaries implements ports.CISummaryStore with one Markdown file per
4+// step under $ERMITE_DATA_DIR/ci/summaries/<run id>/<job id>/<attempt>/<step>.md.
5+//
6+// A summary is written to a temporary file of the same directory, synced, then
7+// renamed over the final name: a reader sees the old summary or the new one,
8+// never a part. The files of a run go with the logs of the run: the use case
9+// that removes the logs removes them too.
10+package cisummaries
11+
12+import (
13+ "context"
14+ "errors"
15+ "fmt"
16+ "io"
17+ "io/fs"
18+ "os"
19+ "path/filepath"
20+ "slices"
21+ "strconv"
22+ "strings"
23+
24+ "bnema.dev/ermite/internal/domain"
25+ "bnema.dev/ermite/internal/ports"
26+)
27+
28+// Dir is the directory of the summaries under the data directory.
29+func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "summaries") }
30+
31+const ext = ".md"
32+
33+// Store is the file-backed summary store.
34+type Store struct{ root string }
35+
36+var _ ports.CISummaryStore = (*Store)(nil)
37+
38+// New returns a store rooted at dir, which need not exist yet.
39+func New(dir string) *Store { return &Store{root: dir} }
40+
41+// validRef rejects a reference that could escape the directory.
42+func validRef(ref ports.CILogRef) bool {
43+ if ref.RunID < 1 || ref.Attempt < 1 {
44+ return false
45+ }
46+ _, _, _, ok := domain.ParseJobID(ref.JobID)
47+ return ok
48+}
49+
50+func (s *Store) jobDir(ref ports.CILogRef) string {
51+ return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt)))
52+}
53+
54+func (s *Store) path(ref ports.CILogRef, step int32) string {
55+ return filepath.Join(s.jobDir(ref), strconv.Itoa(int(step))+ext)
56+}
57+
58+// Put stores the summary of a step, or removes it when markdown is empty.
59+func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
60+ if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
61+ return domain.ErrNotFound
62+ }
63+ if markdown == "" {
64+ if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
65+ return fmt.Errorf("remove summary: %w", err)
66+ }
67+ return nil
68+ }
69+ dir := s.jobDir(ref)
70+ if err := os.MkdirAll(dir, 0o700); err != nil {
71+ return fmt.Errorf("create summary directory: %w", err)
72+ }
73+ f, err := os.CreateTemp(dir, ".tmp-*")
74+ if err != nil {
75+ return fmt.Errorf("create summary: %w", err)
76+ }
77+ tmp := f.Name()
78+ _, werr := f.WriteString(markdown)
79+ serr := f.Sync()
80+ cerr := f.Close()
81+ if err := errors.Join(werr, serr, cerr); err != nil {
82+ _ = os.Remove(tmp)
83+ return fmt.Errorf("write summary: %w", err)
84+ }
85+ if err := os.Rename(tmp, s.path(ref, step)); err != nil {
86+ _ = os.Remove(tmp)
87+ return fmt.Errorf("store summary: %w", err)
88+ }
89+ return nil
90+}
91+
92+// List returns the summaries of the steps that have one, by step number.
93+func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
94+ if !validRef(ref) {
95+ return nil, false, nil
96+ }
97+ entries, err := os.ReadDir(s.jobDir(ref))
98+ if errors.Is(err, fs.ErrNotExist) {
99+ return nil, false, nil
100+ }
101+ if err != nil {
102+ return nil, false, fmt.Errorf("list summaries: %w", err)
103+ }
104+ var steps []int32
105+ for _, e := range entries {
106+ name, ok := strings.CutSuffix(e.Name(), ext)
107+ if !ok || e.IsDir() {
108+ continue // a temporary file, or something else
109+ }
110+ n, err := strconv.ParseInt(name, 10, 32)
111+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
112+ continue
113+ }
114+ steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
115+ }
116+ slices.Sort(steps)
117+ more := limit > 0 && len(steps) > limit
118+ if more {
119+ steps = steps[:limit]
120+ }
121+ out := make([]domain.CIStepSummary, 0, len(steps))
122+ var read int64
123+ exhausted := false
124+ for _, n := range steps {
125+ if exhausted {
126+ out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
127+ continue
128+ }
129+ if maxBytes > 0 {
130+ // Size the file first: a summary that does not fit is not read.
131+ info, err := os.Stat(s.path(ref, n))
132+ if errors.Is(err, fs.ErrNotExist) {
133+ continue // removed meanwhile
134+ }
135+ if err != nil {
136+ return nil, false, fmt.Errorf("stat summary: %w", err)
137+ }
138+ if read+info.Size() > maxBytes {
139+ exhausted = true
140+ out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
141+ continue
142+ }
143+ }
144+ b, err := readCapped(s.path(ref, n), maxBytes, read)
145+ if errors.Is(err, fs.ErrNotExist) {
146+ continue // removed meanwhile
147+ }
148+ if errors.Is(err, errOverBudget) {
149+ exhausted = true // replaced by a larger file since the stat
150+ out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
151+ continue
152+ }
153+ if err != nil {
154+ return nil, false, fmt.Errorf("read summary: %w", err)
155+ }
156+ read += int64(len(b))
157+ out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
158+ }
159+ return out, more, nil
160+}
161+
162+var errOverBudget = errors.New("over budget")
163+
164+// readCapped reads a file, at most what is left of maxBytes after read bytes
165+// (no cap when maxBytes is 0): a file that is longer than that, because it grew
166+// since it was sized, is errOverBudget and its content is dropped.
167+func readCapped(path string, maxBytes, read int64) ([]byte, error) {
168+ f, err := os.Open(path)
169+ if err != nil {
170+ return nil, err
171+ }
172+ defer func() { _ = f.Close() }()
173+ if maxBytes <= 0 {
174+ return io.ReadAll(f)
175+ }
176+ left := maxBytes - read
177+ b, err := io.ReadAll(io.LimitReader(f, left+1))
178+ if err != nil {
179+ return nil, err
180+ }
181+ if int64(len(b)) > left {
182+ return nil, errOverBudget
183+ }
184+ return b, nil
185+}
186+
187+// RemoveJob deletes the summaries of one execution of a job.
188+func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
189+ if !validRef(ref) {
190+ return nil
191+ }
192+ if err := os.RemoveAll(s.jobDir(ref)); err != nil {
193+ return fmt.Errorf("remove summaries: %w", err)
194+ }
195+ return nil
196+}
197+
198+// RemoveRuns deletes the summaries of the runs.
199+func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
200+ var errs []error
201+ for _, id := range runIDs {
202+ if id < 1 {
203+ continue
204+ }
205+ if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
206+ errs = append(errs, err)
207+ }
208+ }
209+ return errors.Join(errs...)
210+}
211+
212+// RunIDs lists the runs that have a summary directory.
213+func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
214+ entries, err := os.ReadDir(s.root)
215+ if errors.Is(err, fs.ErrNotExist) {
216+ return nil, nil
217+ }
218+ if err != nil {
219+ return nil, fmt.Errorf("list summaries: %w", err)
220+ }
221+ var ids []int64
222+ for _, e := range entries {
223+ if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
224+ ids = append(ids, id)
225+ }
226+ }
227+ return ids, nil
228+}
A internal/adapters/cisummaries/cisummaries_test.go +211 -0
@@ -0,0 +1,211 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package cisummaries_test
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/adapters/cisummaries"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+var ref = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1}
20+
21+func TestPutListReplaceAndRemove(t *testing.T) {
22+ ctx := t.Context()
23+ root := t.TempDir()
24+ s := cisummaries.New(root)
25+
26+ got, more, err := s.List(ctx, ref, 20, 0)
27+ require.NoError(t, err)
28+ assert.Empty(t, got)
29+ assert.False(t, more)
30+
31+ require.NoError(t, s.Put(ctx, ref, 10, "# ten"))
32+ require.NoError(t, s.Put(ctx, ref, 2, "two"))
33+ // The layout is the documented one.
34+ b, err := os.ReadFile(filepath.Join(root, "7", "7-0-0", "1", "2.md"))
35+ require.NoError(t, err)
36+ assert.Equal(t, "two", string(b))
37+
38+ got, _, err = s.List(ctx, ref, 20, 0)
39+ require.NoError(t, err)
40+ assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name")
41+
42+ // Replace, and an empty markdown removes.
43+ require.NoError(t, s.Put(ctx, ref, 2, "again"))
44+ got, _, _ = s.List(ctx, ref, 20, 0)
45+ assert.Equal(t, "again", got[0].Markdown)
46+ require.NoError(t, s.Put(ctx, ref, 2, ""))
47+ require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine")
48+ got, _, _ = s.List(ctx, ref, 20, 0)
49+ require.Len(t, got, 1)
50+ assert.EqualValues(t, 10, got[0].Step)
51+
52+ // Another attempt and another job have their own.
53+ other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2}
54+ got, _, _ = s.List(ctx, other, 20, 0)
55+ assert.Empty(t, got)
56+
57+ // A write leaves no temporary file behind.
58+ entries, err := os.ReadDir(filepath.Join(root, "7", "7-0-0", "1"))
59+ require.NoError(t, err)
60+ for _, e := range entries {
61+ assert.False(t, strings.HasPrefix(e.Name(), ".tmp"), e.Name())
62+ }
63+}
64+
65+func TestListStopsAtTheLimit(t *testing.T) {
66+ ctx := t.Context()
67+ s := cisummaries.New(t.TempDir())
68+ for i := int32(1); i <= 25; i++ {
69+ require.NoError(t, s.Put(ctx, ref, i, "s"))
70+ }
71+ got, more, err := s.List(ctx, ref, 20, 0)
72+ require.NoError(t, err)
73+ assert.Len(t, got, 20)
74+ assert.True(t, more)
75+ assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one")
76+ got, more, _ = s.List(ctx, ref, 25, 0)
77+ assert.Len(t, got, 25)
78+ assert.False(t, more)
79+}
80+
81+// A temporary file left by a crash, or a stray file, is not a summary.
82+func TestListIgnoresStrayFiles(t *testing.T) {
83+ ctx := t.Context()
84+ root := t.TempDir()
85+ s := cisummaries.New(root)
86+ require.NoError(t, s.Put(ctx, ref, 1, "one"))
87+ dir := filepath.Join(root, "7", "7-0-0", "1")
88+ for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} {
89+ require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600))
90+ }
91+ got, _, err := s.List(ctx, ref, 20, 0)
92+ require.NoError(t, err)
93+ require.Len(t, got, 1)
94+ assert.Equal(t, "one", got[0].Markdown)
95+}
96+
97+func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) {
98+ ctx := t.Context()
99+ s := cisummaries.New(t.TempDir())
100+ ids, err := s.RunIDs(ctx)
101+ require.NoError(t, err)
102+ assert.Empty(t, ids, "no directory yet")
103+
104+ second := ports.CILogRef{RunID: 7, JobID: "7-1-0", Attempt: 1}
105+ third := ports.CILogRef{RunID: 8, JobID: "8-0-0", Attempt: 1}
106+ for _, r := range []ports.CILogRef{ref, second, third} {
107+ require.NoError(t, s.Put(ctx, r, 1, "x"))
108+ }
109+ ids, _ = s.RunIDs(ctx)
110+ assert.ElementsMatch(t, []int64{7, 8}, ids)
111+
112+ require.NoError(t, s.RemoveJob(ctx, ref))
113+ got, _, _ := s.List(ctx, ref, 20, 0)
114+ assert.Empty(t, got)
115+ got, _, _ = s.List(ctx, second, 20, 0)
116+ assert.Len(t, got, 1, "the other job of the run stays")
117+
118+ require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99}))
119+ got, _, _ = s.List(ctx, second, 20, 0)
120+ assert.Empty(t, got)
121+ got, _, _ = s.List(ctx, third, 20, 0)
122+ assert.Len(t, got, 1)
123+ ids, _ = s.RunIDs(ctx)
124+ assert.Equal(t, []int64{8}, ids)
125+}
126+
127+// A reference that could leave the directory is refused, never joined.
128+func TestRefsCannotEscape(t *testing.T) {
129+ ctx := t.Context()
130+ root := t.TempDir()
131+ s := cisummaries.New(filepath.Join(root, "summaries"))
132+ for _, bad := range []ports.CILogRef{
133+ {RunID: 7, JobID: "../../x", Attempt: 1},
134+ {RunID: 7, JobID: "7-0-0/../..", Attempt: 1},
135+ {RunID: 0, JobID: "7-0-0", Attempt: 1},
136+ {RunID: 7, JobID: "7-0-0", Attempt: 0},
137+ } {
138+ assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound)
139+ got, _, err := s.List(ctx, bad, 20, 0)
140+ require.NoError(t, err)
141+ assert.Empty(t, got)
142+ }
143+ assert.ErrorIs(t, s.Put(ctx, ref, 0, "x"), domain.ErrNotFound)
144+ assert.ErrorIs(t, s.Put(ctx, ref, domain.CIMaxSteps+1, "x"), domain.ErrNotFound)
145+ entries, _ := os.ReadDir(root)
146+ assert.Empty(t, entries, "nothing was written")
147+}
148+
149+func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) {
150+ ctx := t.Context()
151+ s := cisummaries.New(t.TempDir())
152+ big := strings.Repeat("x", domain.CIMaxSummaryBytes)
153+ require.NoError(t, s.Put(ctx, ref, 1, big))
154+ got, _, err := s.List(ctx, ref, 20, 0)
155+ require.NoError(t, err)
156+ assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes)
157+}
158+
159+// With a byte budget the files are sized before they are read: a summary that
160+// does not fit, and every one after it, comes back Omitted and is never opened.
161+func TestListReadsNoMoreThanTheBudget(t *testing.T) {
162+ ctx := t.Context()
163+ root := t.TempDir()
164+ s := cisummaries.New(root)
165+ require.NoError(t, s.Put(ctx, ref, 1, strings.Repeat("a", 100)))
166+ require.NoError(t, s.Put(ctx, ref, 2, strings.Repeat("b", 100)))
167+ require.NoError(t, s.Put(ctx, ref, 3, strings.Repeat("c", 10)))
168+ require.NoError(t, s.Put(ctx, ref, 4, "d"))
169+ dir := filepath.Join(root, "7", "7-0-0", "1")
170+ // A file that cannot be opened proves it was sized, not read.
171+ if os.Geteuid() != 0 {
172+ for _, n := range []string{"2.md", "3.md", "4.md"} {
173+ require.NoError(t, os.Chmod(filepath.Join(dir, n), 0))
174+ }
175+ }
176+
177+ got, more, err := s.List(ctx, ref, 20, 150)
178+ require.NoError(t, err)
179+ assert.False(t, more)
180+ require.Len(t, got, 4)
181+ assert.Equal(t, strings.Repeat("a", 100), got[0].Markdown)
182+ assert.False(t, got[0].Omitted)
183+ for _, o := range got[1:] {
184+ assert.True(t, o.Omitted, "step %d", o.Step)
185+ assert.Empty(t, o.Markdown)
186+ }
187+ assert.EqualValues(t, []int32{1, 2, 3, 4}, []int32{got[0].Step, got[1].Step, got[2].Step, got[3].Step}, "the steps are still listed")
188+}
189+
190+func TestListBudgetBoundaries(t *testing.T) {
191+ ctx := t.Context()
192+ s := cisummaries.New(t.TempDir())
193+ require.NoError(t, s.Put(ctx, ref, 1, "12345"))
194+ require.NoError(t, s.Put(ctx, ref, 2, "678"))
195+
196+ got, _, _ := s.List(ctx, ref, 20, 8)
197+ assert.False(t, got[0].Omitted || got[1].Omitted, "exactly the budget fits")
198+ got, _, _ = s.List(ctx, ref, 20, 7)
199+ assert.False(t, got[0].Omitted)
200+ assert.True(t, got[1].Omitted, "one byte over")
201+ got, _, _ = s.List(ctx, ref, 20, 4)
202+ assert.True(t, got[0].Omitted && got[1].Omitted, "the first does not fit")
203+ got, _, _ = s.List(ctx, ref, 20, 0)
204+ assert.False(t, got[0].Omitted || got[1].Omitted, "no budget, no bound")
205+
206+ // A smaller file after one that does not fit is still omitted: the order
207+ // of steps is kept and nothing is read past the first omission.
208+ require.NoError(t, s.Put(ctx, ref, 3, "x"))
209+ got, _, _ = s.List(ctx, ref, 20, 7)
210+ assert.True(t, got[2].Omitted)
211+}
M internal/adapters/kvstore/ci.go +6 -3
@@ -38,7 +38,8 @@ import (
3838 // ciactive/<job key> job assigned or running
3939 // jobtoken/<hash> job token (value: job key)
4040 //
41-// The job repository tokens and the action resolutions are in ci_token.go.
41+// The job repository tokens and the action resolutions are in ci_token.go, the
42+// annotations in ci_annotation.go.
4243 const (
4344 runPrefix = "run/"
4445 runSrcPrefix = "runsrc/"
@@ -315,7 +316,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
315316 steps = append(steps, &storagepb.CIStep{
316317 Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
317318 StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
318- LogStart: s.LogStart, HasLines: s.HasLines,
319+ LogStart: s.LogStart, HasLines: s.HasLines, Outcome: encodeConclusion(s.Outcome),
319320 })
320321 }
321322 pb := &storagepb.CIJob{
@@ -367,7 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
367368 j.Steps = append(j.Steps, domain.CIStep{
368369 Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
369370 StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
370- LogStart: s.GetLogStart(), HasLines: s.GetHasLines(),
371+ LogStart: s.GetLogStart(), HasLines: s.GetHasLines(), Outcome: decodeConclusion(s.GetOutcome()),
371372 })
372373 }
373374 return j, nil
@@ -510,6 +511,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
510511 }
511512 }
512513 c.stageRepoTokens(ctx, b, w, guarded)
514+ c.stageAnnotations(ctx, b, w, guarded)
513515 // Checks are derived from the jobs, written in the same batch and under
514516 // the lock of their run: they are upserted without a precondition.
515517 for _, ck := range w.Checks {
@@ -836,6 +838,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
836838 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
837839 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
838840 keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
841+ keys = append(keys, annotationKeys(ctx, snap, runID)...)
839842 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
840843 keys = append(keys, slices.Clone(key))
841844 if j, err := decodeJob(item); err == nil {
A internal/adapters/kvstore/ci_annotation.go +150 -0
@@ -0,0 +1,150 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "strconv"
10+ "strings"
11+
12+ kv "bnema.dev/ermite-kv"
13+ "google.golang.org/protobuf/proto"
14+
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Key layout of the annotations, written by CI.Apply with the job they belong
21+// to, under the lock of its run:
22+//
23+// annot/<run:020d>/<attempt:010d>/<job id>/<step:010d> CIStepAnnotations
24+//
25+// The attempt is the one in which the job executed (CIJob.Attempt, the one of
26+// its log), not the attempt of the run that keeps the record. One value holds
27+// all the annotations of one step, so that a report replaces them whole; a
28+// step without annotation has no key.
29+const annotPrefix = "annot/"
30+
31+func annotRunPrefix(runID int64) string { return fmt.Sprintf("%s%020d/", annotPrefix, runID) }
32+func annotJobPrefix(runID int64, attempt int32, jobID string) string {
33+ return fmt.Sprintf("%s%010d/%s/", annotRunPrefix(runID), attempt, jobID)
34+}
35+func annotKey(runID int64, attempt int32, jobID string, step int32) []byte {
36+ return []byte(fmt.Sprintf("%s%010d", annotJobPrefix(runID, attempt, jobID), step))
37+}
38+
39+var annotationLevels = []struct {
40+ d domain.AnnotationLevel
41+ p storagepb.CIAnnotationLevel
42+}{
43+ {domain.AnnotationNotice, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_NOTICE},
44+ {domain.AnnotationWarning, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_WARNING},
45+ {domain.AnnotationFailure, storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_FAILURE},
46+}
47+
48+func encodeAnnotationLevel(l domain.AnnotationLevel) storagepb.CIAnnotationLevel {
49+ for _, e := range annotationLevels {
50+ if e.d == l {
51+ return e.p
52+ }
53+ }
54+ return storagepb.CIAnnotationLevel_CI_ANNOTATION_LEVEL_UNSPECIFIED
55+}
56+
57+func decodeAnnotationLevel(l storagepb.CIAnnotationLevel) domain.AnnotationLevel {
58+ for _, e := range annotationLevels {
59+ if e.p == l {
60+ return e.d
61+ }
62+ }
63+ return domain.AnnotationNotice
64+}
65+
66+func encodeAnnotations(items []domain.CIAnnotation) []byte {
67+ pb := &storagepb.CIStepAnnotations{Annotations: make([]*storagepb.CIAnnotation, 0, len(items))}
68+ for _, a := range items {
69+ pb.Annotations = append(pb.Annotations, &storagepb.CIAnnotation{
70+ Level: encodeAnnotationLevel(a.Level), Title: a.Title, Message: a.Message, File: a.File,
71+ StartLine: a.StartLine, EndLine: a.EndLine, StartColumn: a.StartColumn, EndColumn: a.EndColumn,
72+ })
73+ }
74+ return mustMarshal(pb)
75+}
76+
77+func decodeAnnotations(val []byte, step int32) ([]domain.CIAnnotation, error) {
78+ var pb storagepb.CIStepAnnotations
79+ if err := proto.Unmarshal(val, &pb); err != nil {
80+ return nil, fmt.Errorf("decode annotations: %w", err)
81+ }
82+ out := make([]domain.CIAnnotation, 0, len(pb.GetAnnotations()))
83+ for _, a := range pb.GetAnnotations() {
84+ out = append(out, domain.CIAnnotation{
85+ Level: decodeAnnotationLevel(a.GetLevel()), Title: a.GetTitle(), Message: a.GetMessage(), File: a.GetFile(),
86+ StartLine: a.GetStartLine(), EndLine: a.GetEndLine(), StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
87+ Step: step,
88+ })
89+ }
90+ return out, nil
91+}
92+
93+// stageAnnotations stages the deletion of the annotations of the executions to
94+// clear, then the replacement of the annotations of the steps to set.
95+//
96+// Annotations normally travel with the write of their job, whose version
97+// precondition fails once the job is gone; guarding the repository key as well
98+// would make the jobs of one repository conflict with one another at every
99+// report. Only a batch without a job guards it. A report that races the
100+// deletion of its repository can leave its annotation keys behind, under a run
101+// id that is never reused: harmless, and a few kilobytes at most.
102+func (c *CI) stageAnnotations(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) {
103+ if len(w.ClearAnnotations) > 0 {
104+ snap := c.db.Snapshot(ctx)
105+ for _, e := range w.ClearAnnotations {
106+ for key := range snap.Prefix(ctx, []byte(annotJobPrefix(e.RunID, e.Attempt, e.JobID))) {
107+ b.Delete(slices.Clone(key))
108+ }
109+ }
110+ }
111+ for _, s := range w.Annotations {
112+ key := annotKey(s.Exec.RunID, s.Exec.Attempt, s.Exec.JobID, s.Step)
113+ if len(s.Items) == 0 {
114+ b.Delete(key)
115+ continue
116+ }
117+ b.Put(key, encodeAnnotations(s.Items))
118+ if len(w.Jobs) == 0 {
119+ guarded[s.Repo] = true
120+ }
121+ }
122+}
123+
124+// JobAnnotations returns the annotations of one execution of a job, ordered by
125+// step then by the order the step produced them.
126+func (c *CI) JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error) {
127+ prefix := annotJobPrefix(runID, attempt, jobID)
128+ var out []domain.CIAnnotation
129+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
130+ n, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 32)
131+ if err != nil {
132+ return nil, fmt.Errorf("decode %s: %w", key, err)
133+ }
134+ items, err := decodeAnnotations(item.Value, int32(n)) //nolint:gosec // parsed with a 32-bit size
135+ if err != nil {
136+ return nil, fmt.Errorf("decode %s: %w", key, err)
137+ }
138+ out = append(out, items...)
139+ }
140+ return out, nil
141+}
142+
143+// annotationKeys lists the keys of the annotations of a run, for its deletion.
144+func annotationKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
145+ var keys [][]byte
146+ for key := range snap.Prefix(ctx, []byte(annotRunPrefix(runID))) {
147+ keys = append(keys, slices.Clone(key))
148+ }
149+ return keys
150+}
A internal/adapters/kvstore/ci_annotation_test.go +171 -0
@@ -0,0 +1,171 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+func note(level domain.AnnotationLevel, step int32, msg string) domain.CIAnnotation {
18+ return domain.CIAnnotation{Level: level, Message: msg, Step: step}
19+}
20+
21+func replace(n domain.RepoName, run int64, attempt int32, job string, step int32, items ...domain.CIAnnotation) ports.CIStepAnnotations {
22+ return ports.CIStepAnnotations{Repo: n, Exec: ports.CILogRef{RunID: run, JobID: job, Attempt: attempt}, Step: step, Items: items}
23+}
24+
25+// Annotations are kept per execution of a job and per step: a report replaces
26+// the annotations of the steps it names, keeps the others, and an empty report
27+// removes the step's.
28+func TestCIAnnotationsAreReplacedPerStep(t *testing.T) {
29+ ctx := context.Background()
30+ _, ci, n := ciRepo(t)
31+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
32+ require.NoError(t, err)
33+ a, b := d.Jobs[0].ID, d.Jobs[1].ID
34+
35+ full := domain.CIAnnotation{
36+ Level: domain.AnnotationFailure, Title: "t", Message: "boom", File: "a/b.go",
37+ StartLine: 3, EndLine: 5, StartColumn: 2, EndColumn: 9, Step: 2,
38+ }
39+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
40+ replace(n, d.Run.ID, 1, a, 2, full, note(domain.AnnotationNotice, 2, "second")),
41+ replace(n, d.Run.ID, 1, a, 10, note(domain.AnnotationWarning, 10, "late step")),
42+ replace(n, d.Run.ID, 1, b, 2, note(domain.AnnotationNotice, 2, "other job")),
43+ }})
44+ require.NoError(t, err)
45+
46+ got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, a)
47+ require.NoError(t, err)
48+ require.Len(t, got, 3)
49+ assert.Equal(t, full, got[0], "every field round-trips, in the order the step produced them")
50+ assert.Equal(t, "second", got[1].Message)
51+ assert.EqualValues(t, 10, got[2].Step, "steps sort numerically, not as text")
52+
53+ // Replace step 2 only: step 10 and the other job keep theirs.
54+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
55+ replace(n, d.Run.ID, 1, a, 2, note(domain.AnnotationWarning, 2, "only one now")),
56+ }})
57+ require.NoError(t, err)
58+ got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
59+ require.Len(t, got, 2)
60+ assert.Equal(t, "only one now", got[0].Message)
61+ assert.Equal(t, "late step", got[1].Message)
62+ other, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, b)
63+ require.Len(t, other, 1)
64+
65+ // An empty list removes the step's annotations.
66+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{replace(n, d.Run.ID, 1, a, 2)}})
67+ require.NoError(t, err)
68+ got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
69+ require.Len(t, got, 1)
70+ assert.EqualValues(t, 10, got[0].Step)
71+
72+ // Another attempt of the job has its own.
73+ none, err := ci.JobAnnotations(ctx, d.Run.ID, 2, a)
74+ require.NoError(t, err)
75+ assert.Empty(t, none)
76+}
77+
78+// The annotations are written in the batch of the job: a stale job version
79+// writes none of them, and a missing repository neither.
80+func TestCIAnnotationsAreAtomicWithTheJob(t *testing.T) {
81+ ctx := context.Background()
82+ _, ci, n := ciRepo(t)
83+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
84+ require.NoError(t, err)
85+ job, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
86+ require.NoError(t, err)
87+
88+ stale := job
89+ stale.Version--
90+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{stale}, Annotations: []ports.CIStepAnnotations{
91+ replace(n, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "lost")),
92+ }})
93+ assert.ErrorIs(t, err, domain.ErrConflict)
94+ got, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, job.ID)
95+ assert.Empty(t, got)
96+
97+ ghost := name(t, "ghost")
98+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
99+ replace(ghost, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "no repository")),
100+ }})
101+ assert.ErrorIs(t, err, domain.ErrNotFound, "no key is created under a repository that is gone")
102+}
103+
104+// Annotations go with their run: the retention sweep, the deletion of the
105+// repository, and the clearing of an assignment that is taken back.
106+func TestCIAnnotationsAreDeleted(t *testing.T) {
107+ ctx := context.Background()
108+ s, ci, n := ciRepo(t)
109+ mk := func() ports.CIRunDraft {
110+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
111+ require.NoError(t, err)
112+ _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
113+ replace(n, d.Run.ID, 1, d.Jobs[0].ID, 1, note(domain.AnnotationNotice, 1, "x")),
114+ replace(n, d.Run.ID, 1, d.Jobs[0].ID, 2, note(domain.AnnotationNotice, 2, "y")),
115+ }})
116+ require.NoError(t, err)
117+ return d
118+ }
119+ count := func(d ports.CIRunDraft) int {
120+ got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, d.Jobs[0].ID)
121+ require.NoError(t, err)
122+ return len(got)
123+ }
124+
125+ // clear
126+ d := mk()
127+ _, err := ci.Apply(ctx, ports.CIWrite{ClearAnnotations: []ports.CILogRef{{RunID: d.Run.ID, JobID: d.Jobs[0].ID, Attempt: 1}}})
128+ require.NoError(t, err)
129+ assert.Zero(t, count(d))
130+
131+ // sweep: an old completed run goes with its annotations, a recent one keeps them.
132+ oldRun, recent := mk(), mk()
133+ for _, tc := range []struct {
134+ d ports.CIRunDraft
135+ at time.Time
136+ }{{oldRun, created}, {recent, created.Add(100 * 24 * time.Hour)}} {
137+ run, _ := ci.GetRun(ctx, n, tc.d.Run.ID)
138+ run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, tc.at
139+ job, _ := ci.GetJob(ctx, run.ID, 1, tc.d.Jobs[0].ID)
140+ job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
141+ _, err := ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
142+ require.NoError(t, err)
143+ }
144+ ids, err := ci.Sweep(ctx, created.Add(90*24*time.Hour))
145+ require.NoError(t, err)
146+ assert.Contains(t, ids, oldRun.Run.ID)
147+ assert.Zero(t, count(oldRun))
148+ assert.Equal(t, 2, count(recent))
149+
150+ // repository deletion
151+ require.NoError(t, s.Delete(ctx, n))
152+ assert.Zero(t, count(recent))
153+}
154+
155+func TestCIStepOutcomeRoundTrips(t *testing.T) {
156+ ctx := context.Background()
157+ _, ci, n := ciRepo(t)
158+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
159+ require.NoError(t, err)
160+ job, _ := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
161+ job.Steps = []domain.CIStep{
162+ {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure, StartedAt: created, CompletedAt: created},
163+ {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: created, CompletedAt: created},
164+ }
165+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{job}})
166+ require.NoError(t, err)
167+ got, err := ci.GetJob(ctx, d.Run.ID, 1, job.ID)
168+ require.NoError(t, err)
169+ assert.Equal(t, domain.CIFailure, got.Steps[0].Outcome, "continue-on-error: failed, then succeeded")
170+ assert.Equal(t, domain.CINoConclusion, got.Steps[1].Outcome, "stored without one: read as the conclusion")
171+}
M internal/adapters/rpc/api.go +8 -2
@@ -25,10 +25,15 @@ import (
2525 // bounded: a diff has its own limits and blobs are streamed.
2626 const MaxRequestBytes = 1 << 20
2727
28+// RunnerMaxRequestBytes bounds the size of a request message of RunnerService:
29+// a job summary of exactly 1 MiB (UploadSummary) must be accepted, so the limit
30+// leaves room for the envelope (job id, step number, framing).
31+const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
32+
2833 // Features are the optional capabilities of the contract that this server
2934 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3035 // when Config.Passkeys is set.
31-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}
36+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}
3237
3338 // FeaturePasskeys is listed by GetVersion when passkeys are available.
3439 const FeaturePasskeys = "passkeys"
@@ -100,7 +105,8 @@ func New(cfg Config) *API {
100105 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
101106 }
102107 if cfg.Runner != nil {
103- mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
108+ runnerOpts := append(slices.Clone(opts), connect.WithReadMaxBytes(RunnerMaxRequestBytes))
109+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, runnerOpts...))
104110 }
105111 if cfg.Runs != nil {
106112 mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
M internal/adapters/rpc/ci.go +46 -13
@@ -105,7 +105,11 @@ func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.U
105105 if err != nil {
106106 return nil, s.failed(ctx, err)
107107 }
108- if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil {
108+ anns, err := annotationsFromPB(req.Msg.GetAnnotations())
109+ if err != nil {
110+ return nil, s.failed(ctx, err)
111+ }
112+ if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps, anns); err != nil {
109113 return nil, s.failed(ctx, err)
110114 }
111115 return connect.NewResponse(&pb.UpdateJobResponse{}), nil
@@ -162,9 +166,13 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb
162166 if err != nil {
163167 return nil, s.failed(ctx, err)
164168 }
169+ anns, err := annotationsFromPB(req.Msg.GetAnnotations())
170+ if err != nil {
171+ return nil, s.failed(ctx, err)
172+ }
165173 res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{
166174 JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(),
167- RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps,
175+ RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps, Annotations: anns,
168176 })
169177 if err != nil {
170178 return nil, s.failed(ctx, err)
@@ -190,10 +198,12 @@ func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request
190198 return connect.NewResponse(out), nil
191199 }
192200
193-// UploadSummary is the E2 work (summaries storage): until the server lists
194-// "ci.summaries" it answers unimplemented, as runner.proto says.
195-func (s *runnerService) UploadSummary(context.Context, *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) {
196- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.UploadSummary is not implemented"))
201+// UploadSummary stores the summary of a step.
202+func (s *runnerService) UploadSummary(ctx context.Context, req *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) {
203+ if err := s.runners.UploadSummary(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStepNumber(), req.Msg.GetMarkdown()); err != nil {
204+ return nil, s.failed(ctx, err)
205+ }
206+ return connect.NewResponse(&pb.UploadSummaryResponse{}), nil
197207 }
198208
199209 // runService implements ermiteconnect.RunServiceHandler.
@@ -334,13 +344,36 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R
334344 return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
335345 }
336346
337-// ListAnnotations and GetStepSummaries are the E2 work (annotations and
338-// summaries): unimplemented until the server lists "ci.annotations" and
339-// "ci.summaries".
340-func (s *runService) ListAnnotations(context.Context, *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) {
341- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.ListAnnotations is not implemented"))
347+// ListAnnotations lists the annotations of a run attempt.
348+func (s *runService) ListAnnotations(ctx context.Context, req *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) {
349+ name, err := repoOf(req.Msg.GetRepo())
350+ if err != nil {
351+ return nil, ciError(ctx, err)
352+ }
353+ res, err := s.runs.ListAnnotations(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt(), req.Msg.GetJobId())
354+ if err != nil {
355+ return nil, ciError(ctx, err)
356+ }
357+ out := &pb.ListAnnotationsResponse{Truncated: res.Truncated, Annotations: make([]*pb.JobAnnotation, 0, len(res.Annotations))}
358+ for _, a := range res.Annotations {
359+ out.Annotations = append(out.Annotations, &pb.JobAnnotation{JobId: a.JobID, Annotation: annotationToPB(a.Annotation)})
360+ }
361+ return connect.NewResponse(out), nil
342362 }
343363
344-func (s *runService) GetStepSummaries(context.Context, *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) {
345- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.GetStepSummaries is not implemented"))
364+// GetStepSummaries returns the summaries of a job.
365+func (s *runService) GetStepSummaries(ctx context.Context, req *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) {
366+ name, err := repoOf(req.Msg.GetRepo())
367+ if err != nil {
368+ return nil, ciError(ctx, err)
369+ }
370+ res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt(), 0)
371+ if err != nil {
372+ return nil, ciError(ctx, err)
373+ }
374+ out := &pb.GetStepSummariesResponse{Truncated: res.Truncated, Summaries: make([]*pb.StepSummary, 0, len(res.Summaries))}
375+ for _, m := range res.Summaries {
376+ out.Summaries = append(out.Summaries, &pb.StepSummary{StepNumber: m.Step, Markdown: m.Markdown})
377+ }
378+ return connect.NewResponse(out), nil
346379 }
M internal/adapters/rpc/ci_convert.go +67 -2
@@ -149,13 +149,26 @@ func runToPB(r domain.CIRun) *pb.Run {
149149 }
150150 }
151151
152+// stepOutcome is Step.outcome: unset until the step is completed, and for a
153+// step stored without one (read as its conclusion) it is the conclusion, which
154+// is what the contract says UNSPECIFIED means.
155+func stepOutcome(s domain.CIStep) domain.CIConclusion {
156+ if s.State != domain.CIStepCompleted {
157+ return domain.CINoConclusion
158+ }
159+ if s.Outcome == domain.CINoConclusion {
160+ return s.Conclusion
161+ }
162+ return s.Outcome
163+}
164+
152165 func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step {
153166 ranges := domain.StepRanges(steps, lines)
154167 out := make([]*pb.Step, 0, len(steps))
155168 for i, s := range steps {
156169 out = append(out, &pb.Step{
157170 Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion),
158- StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
171+ Outcome: conclusionToPB(stepOutcome(s)), StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
159172 LogStart: ranges[i].Start, LogEnd: ranges[i].End,
160173 })
161174 }
@@ -231,8 +244,12 @@ func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
231244 if err != nil {
232245 return nil, err
233246 }
247+ outcome, err := conclusionFromPB(s.GetOutcome())
248+ if err != nil {
249+ return nil, err
250+ }
234251 out = append(out, ports.StepUpdate{
235- Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl,
252+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl, Outcome: outcome,
236253 StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()),
237254 })
238255 }
@@ -250,3 +267,51 @@ func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated {
250267 func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated {
251268 return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}}
252269 }
270+
271+var annotationLevelPairs = []struct {
272+ d domain.AnnotationLevel
273+ p pb.AnnotationLevel
274+}{
275+ {domain.AnnotationNotice, pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE},
276+ {domain.AnnotationWarning, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING},
277+ {domain.AnnotationFailure, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE},
278+}
279+
280+func annotationLevelToPB(l domain.AnnotationLevel) pb.AnnotationLevel {
281+ for _, e := range annotationLevelPairs {
282+ if e.d == l {
283+ return e.p
284+ }
285+ }
286+ return pb.AnnotationLevel_ANNOTATION_LEVEL_UNSPECIFIED
287+}
288+
289+func annotationToPB(a domain.CIAnnotation) *pb.Annotation {
290+ return &pb.Annotation{
291+ Level: annotationLevelToPB(a.Level), Title: a.Title, Message: a.Message, File: a.File,
292+ StartLine: a.StartLine, EndLine: a.EndLine, StartColumn: a.StartColumn, EndColumn: a.EndColumn, StepNumber: a.Step,
293+ }
294+}
295+
296+// annotationsFromPB maps the annotations of a request; an unset or unknown
297+// level is invalid_argument. The other rules are the use case's.
298+func annotationsFromPB(anns []*pb.Annotation) ([]domain.CIAnnotation, error) {
299+ out := make([]domain.CIAnnotation, 0, len(anns))
300+ for i, a := range anns {
301+ var level domain.AnnotationLevel
302+ for _, e := range annotationLevelPairs {
303+ if e.p == a.GetLevel() {
304+ level = e.d
305+ }
306+ }
307+ if level == "" {
308+ return nil, fmt.Errorf("%w: annotation %d has no level", domain.ErrInvalid, i)
309+ }
310+ out = append(out, domain.CIAnnotation{
311+ Level: level, Title: a.GetTitle(), Message: a.GetMessage(), File: a.GetFile(),
312+ StartLine: a.GetStartLine(), EndLine: a.GetEndLine(), StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
313+ Step: a.GetStepNumber(),
314+ })
315+ }
316+ return out, nil
317+}
M internal/adapters/rpc/ci_test.go +117 -0
@@ -13,6 +13,7 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1617 "bnema.dev/ermite/internal/domain"
1718 "bnema.dev/ermite/internal/ports"
1819 "bnema.dev/ermite/internal/ports/mocks"
@@ -99,3 +100,119 @@ func TestRunnerServiceErrors(t *testing.T) {
99100 requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
100101 requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
101102 }
103+
104+// The annotations of a request are mapped to the domain; a level that is not
105+// one of the three is invalid_argument before the use case sees the call.
106+func TestRunnerServiceAnnotationsAndSummary(t *testing.T) {
107+ t.Parallel()
108+ anns := []*pb.Annotation{
109+ {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, StepNumber: 5},
110+ {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "n", StepNumber: 1},
111+ }
112+ want := []domain.CIAnnotation{
113+ {Level: domain.AnnotationFailure, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, Step: 5},
114+ {Level: domain.AnnotationNotice, Message: "n", Step: 1},
115+ }
116+ ctx := context.WithValue(ownerCtx(), secretKey{}, "ermitejob_x")
117+
118+ runners := mocks.NewRunnerService(t)
119+ runners.EXPECT().UpdateJob(mock.Anything, "ermitejob_x", "7-0-0", mock.Anything, want).Return(nil).Once()
120+ runners.EXPECT().CompleteJob(mock.Anything, "ermitejob_x", mock.MatchedBy(func(p ports.CompleteJobParams) bool {
121+ return p.JobID == "7-0-0" && len(p.Annotations) == 2 && p.Annotations[0] == want[0]
122+ })).Return(ports.CompleteJobResult{Conclusion: domain.CISuccess, Applied: true}, nil).Once()
123+ runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "# md").Return(nil).Once()
124+ runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "").Return(&domain.JobEndedError{Conclusion: domain.CIFailure}).Once()
125+ s := &runnerService{runners: runners}
126+
127+ _, err := s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: anns}))
128+ require.NoError(t, err)
129+ _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: anns}))
130+ require.NoError(t, err)
131+ _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3, Markdown: "# md"}))
132+ require.NoError(t, err)
133+ _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3}))
134+ requireCode(t, connect.CodeFailedPrecondition, err)
135+
136+ _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Message: "no level", StepNumber: 1}}}))
137+ requireCode(t, connect.CodeInvalidArgument, err)
138+ _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Level: 99, Message: "x", StepNumber: 1}}}))
139+ requireCode(t, connect.CodeInvalidArgument, err)
140+ _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{{Message: "x"}}}))
141+ requireCode(t, connect.CodeInvalidArgument, err)
142+}
143+
144+// StepUpdate.outcome reaches the use case, and Step.outcome is the conclusion
145+// for a completed step stored without one, and unset before the end.
146+func TestStepOutcomeMapping(t *testing.T) {
147+ t.Parallel()
148+ ups, err := stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE}})
149+ require.NoError(t, err)
150+ assert.Equal(t, domain.CIFailure, ups[0].Outcome)
151+ _, err = stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, Outcome: 99}})
152+ assert.Error(t, err)
153+
154+ steps := stepsToPB([]domain.CIStep{
155+ {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure},
156+ {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CIFailure},
157+ {Number: 3, State: domain.CIStepInProgress},
158+ {Number: 4, State: domain.CIStepCompleted, Conclusion: domain.CISkipped, Outcome: domain.CISkipped},
159+ }, 0)
160+ got := make([]pb.CiConclusion, len(steps))
161+ for i, s := range steps {
162+ got[i] = s.GetOutcome()
163+ }
164+ assert.Equal(t, []pb.CiConclusion{
165+ pb.CiConclusion_CI_CONCLUSION_FAILURE, pb.CiConclusion_CI_CONCLUSION_FAILURE,
166+ pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED, pb.CiConclusion_CI_CONCLUSION_SKIPPED,
167+ }, got)
168+}
169+
170+func TestRunServiceAnnotationsAndSummaries(t *testing.T) {
171+ t.Parallel()
172+ repo := mustRepoName(t, "demo")
173+ runs := mocks.NewRunService(t)
174+ runs.EXPECT().ListAnnotations(mock.Anything, domain.Owner(), repo, int64(7), int32(2), "7-0-0").Return(ports.AnnotationList{
175+ Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}},
176+ Truncated: true,
177+ }, nil).Once()
178+ runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0), int64(0)).Return(ports.SummaryList{
179+ Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true,
180+ }, nil).Once()
181+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once()
182+ s := &runService{runs: runs}
183+
184+ res, err := s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 7, Attempt: 2, JobId: "7-0-0"}))
185+ require.NoError(t, err)
186+ require.Len(t, res.Msg.GetAnnotations(), 1)
187+ a := res.Msg.GetAnnotations()[0]
188+ assert.Equal(t, "7-0-0", a.GetJobId())
189+ assert.Equal(t, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, a.GetAnnotation().GetLevel())
190+ assert.EqualValues(t, 3, a.GetAnnotation().GetStepNumber())
191+ assert.EqualValues(t, 4, a.GetAnnotation().GetStartLine())
192+ assert.True(t, res.Msg.GetTruncated())
193+
194+ sums, err := s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "demo", JobId: "7-0-0"}))
195+ require.NoError(t, err)
196+ require.Len(t, sums.Msg.GetSummaries(), 1)
197+ assert.EqualValues(t, 2, sums.Msg.GetSummaries()[0].GetStepNumber())
198+ assert.Equal(t, "# s", sums.Msg.GetSummaries()[0].GetMarkdown())
199+ assert.True(t, sums.Msg.GetTruncated())
200+
201+ _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 8}))
202+ requireCode(t, connect.CodeNotFound, err)
203+ _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "../x", RunId: 8}))
204+ requireCode(t, connect.CodeInvalidArgument, err)
205+ _, err = s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "../x"}))
206+ requireCode(t, connect.CodeInvalidArgument, err)
207+}
208+
209+// UploadSummary is a job call: its bearer secret is the job token, not a stored
210+// token, and a summary of exactly 1 MiB passes the size limit of the runner side.
211+func TestUploadSummaryIsAJobCallWithRoomForOneMiB(t *testing.T) {
212+ t.Parallel()
213+ assert.True(t, runnerSecretProcedures[ermiteconnect.RunnerServiceUploadSummaryProcedure])
214+ assert.Greater(t, RunnerMaxRequestBytes, 1<<20+len("7-0-0")+16, "1 MiB plus the envelope")
215+ assert.Equal(t, MaxRequestBytes, 1<<20, "the other services keep the 1 MiB limit")
216+ assert.Contains(t, Features, "ci.annotations")
217+ assert.Contains(t, Features, "ci.summaries")
218+}
M internal/adapters/web/actions.templ +106 -0
@@ -62,6 +62,7 @@ templ RunDetail(l Layout, p RunPage) {
6262 <div id="run-body">
6363 @RunBody(p)
6464 </div>
65+ @RunSummaries(p.Summaries, p.SummariesMore)
6566 }
6667 }
6768
@@ -78,6 +79,12 @@ templ RunBody(p RunPage) {
7879 if p.Error != "" {
7980 <pre class="run-err">{ p.Error }</pre>
8081 }
82+ if len(p.Annotations) > 0 {
83+ <section class="annots" aria-labelledby="run-annots">
84+ <h3 id="run-annots">Annotations <span class="count">{ itoa(len(p.Annotations)) }</span></h3>
85+ @AnnotationList(p.Annotations, p.AnnotationsMore, false)
86+ </section>
87+ }
8188 <ul class="jobs">
8289 for _, j := range p.Jobs {
8390 <li id={ "job-" + j.ID }>
@@ -107,6 +114,104 @@ templ RunBody(p RunPage) {
107114 </ul>
108115 }
109116
117+// AnnotationList is a list of annotations: the level as a mark with its word,
118+// the job and the step, the title and the message, and file:line. Everything
119+// comes from the workflow and is text. Without grouped, each item names its job (the
120+// run page); grouped lists the annotations by step instead (the job page).
121+templ AnnotationList(anns []AnnotationView, more bool, grouped bool) {
122+ <ul class="annot-list">
123+ for _, a := range anns {
124+ <li class={ "annot", "annot-" + a.Kind }>
125+ if grouped && a.StepHead {
126+ <p class="annot-step">
127+ if a.StepHref != "" {
128+ <a href={ templ.SafeURL(a.StepHref) }>{ a.Step }</a>
129+ } else {
130+ { a.Step }
131+ }
132+ </p>
133+ }
134+ <span class={ "an", "an-" + a.Kind } role="img" aria-label={ a.Level } title={ a.Level }></span>
135+ <div class="annot-body">
136+ <p class="annot-head">
137+ <span class="annot-level">{ a.Level }</span>
138+ if !grouped {
139+ if a.JobHref != "" {
140+ <a href={ templ.SafeURL(a.JobHref) }>{ a.Job }</a>
141+ } else {
142+ <span>{ a.Job }</span>
143+ }
144+ <span class="mute">{ a.Step }</span>
145+ }
146+ if a.Title != "" {
147+ <strong>{ a.Title }</strong>
148+ }
149+ </p>
150+ <p class="annot-msg">{ a.Message }</p>
151+ if a.Where != "" {
152+ <p class="annot-where">
153+ if a.WhereHref != "" {
154+ <a href={ templ.SafeURL(a.WhereHref) }>{ a.Where }</a>
155+ } else {
156+ <span>{ a.Where }</span>
157+ }
158+ </p>
159+ }
160+ </div>
161+ </li>
162+ }
163+ </ul>
164+ if more {
165+ <p class="mute">more annotations exist than are shown.</p>
166+ }
167+}
168+
169+// JobAnnotations is the annotations of the job page, replaced by the live
170+// updates; the element exists even when there is none, so that the first one
171+// has something to replace.
172+templ JobAnnotations(anns []AnnotationView, more bool) {
173+ <section id="job-annotations" class="annots" aria-label="Annotations">
174+ if len(anns) > 0 {
175+ <h3>Annotations <span class="count">{ itoa(len(anns)) }</span></h3>
176+ @AnnotationList(anns, more, true)
177+ }
178+ </section>
179+}
180+
181+// RunSummaries is the job summaries of a run, one block per step that wrote
182+// one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs
183+// filtered, images shown as their alt text.
184+templ RunSummaries(sums []JobSummaries, more bool) {
185+ for _, j := range sums {
186+ <section class="summaries" aria-label={ "Summary of " + j.Job }>
187+ <h3>
188+ if j.JobHref != "" {
189+ <a href={ templ.SafeURL(j.JobHref) }>{ j.Job }</a>
190+ } else {
191+ { j.Job }
192+ }
193+ <span class="mute">summary</span>
194+ </h3>
195+ for _, s := range j.Steps {
196+ <article class="prose summary" aria-label={ s.Step } hx-ignore>
197+ <h4 class="summary-step">{ s.Step }</h4>
198+ if s.Note != "" {
199+ <p class="dim">{ s.Note }</p>
200+ } else {
201+ @templ.Raw(s.HTML)
202+ }
203+ </article>
204+ }
205+ if j.More {
206+ <p class="mute">the job has more summaries than the first 20, which are shown.</p>
207+ }
208+ </section>
209+ }
210+ if more {
211+ <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p>
212+ }
213+}
214+
110215 templ runRail(p RunPage) {
111216 <section aria-labelledby="r-run">
112217 <h2 id="r-run">Run</h2>
@@ -186,6 +291,7 @@ templ Job(l Layout, p JobPage) {
186291 @Shell(l, jobRail(p)) {
187292 @LiveSlot("job-notice")
188293 @JobHead(p.JobRow, p.Total)
294+ @JobAnnotations(p.Annotations, p.AnnotationsMore)
189295 <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
190296 if p.Note != "" {
191297 <p class="dim">{ p.Note }</p>
M internal/adapters/web/actions_log.go +3 -0
@@ -51,6 +51,9 @@ type JobPage struct {
5151 // StreamHref is the live feed of the lines (logStream).
5252 StreamHref string
5353 Note string
54+ // Annotations are those of the job, shown above the log.
55+ Annotations []AnnotationView
56+ AnnotationsMore bool
5457 }
5558
5659 // appendLogLine adds the line n of a job log to the lines shown. The markers
M internal/adapters/web/actions_pages.go +25 -2
@@ -173,7 +173,17 @@ type RunPage struct {
173173 Latest int32
174174 Attempts []AttemptLink
175175 Jobs []JobRow
176- Active bool
176+ // Annotations are those of the jobs of the run, AnnotationsMore says that
177+ // the server holds more than it returned.
178+ Annotations []AnnotationView
179+ AnnotationsMore bool
180+ // Summaries are the job summaries, rendered; only the full page has them.
181+ Summaries []JobSummaries
182+ // SummariesMore says that some summaries are not shown (the page budget).
183+ SummariesMore bool
184+ Active bool
185+ // detail is what the page was built from.
186+ detail ports.RunDetail
177187 // Forms are the owner's actions; nil/empty when the visitor cannot.
178188 CancelAction, RerunAction, RerunFailedAction string
179189 CanRerunFailed bool
@@ -284,6 +294,11 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int
284294 if err != nil {
285295 return nil, 0, err
286296 }
297+ // The summaries are part of the page, not of the live body: the page
298+ // fetches itself again when the run completes (runBody), and they show then.
299+ if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil {
300+ return nil, 0, err
301+ }
287302 l := s.repoLayout(ctx, d, "actions")
288303 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
289304 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
@@ -303,7 +318,7 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int
303318 run := detail.Run
304319 p := RunPage{
305320 RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
306- Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
321+ Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail,
307322 }
308323 for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
309324 p.Attempts = append(p.Attempts, AttemptLink{
@@ -318,6 +333,11 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int
318333 }
319334 p.Jobs = append(p.Jobs, row)
320335 }
336+ // The annotations of the attempt shown (the jobs kept from an earlier attempt
337+ // by a re-run bring their own).
338+ if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil {
339+ return RunPage{}, err
340+ }
321341 if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
322342 base := runHref(d.name(), id)
323343 if p.Active {
@@ -382,6 +402,9 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
382402 p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
383403 }
384404 p.Follow = !view.Ended()
405+ if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil {
406+ return nil, 0, err
407+ }
385408 p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
386409 lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
387410 if err != nil {
A internal/adapters/web/actions_results.go +251 -0
@@ -0,0 +1,251 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "html/template"
8+ "path"
9+ "strconv"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The results of a job on the CI pages: the annotations (the "Annotations" box
17+// of the run page, and at their step on the job page) and the job summaries
18+// under the run. Both are untrusted text of the workflow: annotations are
19+// escaped by the templates, summaries go through the Markdown renderer of the
20+// READMEs (raw HTML dropped, URLs filtered, images shown as their alt text).
21+
22+// maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries
23+// together; the ones past it are not shown, with a note.
24+const maxSummaryPageBytes int64 = 4 << 20
25+
26+// AnnotationView is an annotation as shown.
27+type AnnotationView struct {
28+ // Kind is the colour class (notice, warning, failure) and Level its word.
29+ Kind, Level string
30+ Job, JobHref string
31+ // Step is the name of the step and StepHref jumps to its lines in the log
32+ // of the job.
33+ Step, StepHref string
34+ Title, Message string
35+ // Where is "file:line", WhereHref the file at the commit of the run, "" when
36+ // the path is not one of the repository.
37+ Where, WhereHref string
38+ // StepHead is set on the first annotation of a step in a list grouped by
39+ // step (the job page).
40+ StepHead bool
41+}
42+
43+// stepInfo is what the views need of the step an annotation belongs to.
44+type stepInfo struct {
45+ name string
46+ start, end int64
47+}
48+
49+// jobInfo is what the annotation views need of a job.
50+type jobInfo struct {
51+ name, href string
52+ steps map[int32]stepInfo
53+}
54+
55+func jobInfoOf(repo string, runID int64, j ports.JobView, linkJob bool) jobInfo {
56+ info := jobInfo{name: visibleText(j.Name), href: jobHref(repo, runID, j.ID), steps: map[int32]stepInfo{}}
57+ if !linkJob {
58+ info.href = ""
59+ }
60+ for i, st := range j.Steps {
61+ r := j.StepRanges()[i]
62+ info.steps[st.Number] = stepInfo{name: visibleText(st.Name), start: r.Start, end: r.End}
63+ }
64+ return info
65+}
66+
67+func annotationKind(l domain.AnnotationLevel) (kind, word string) {
68+ switch l {
69+ case domain.AnnotationFailure:
70+ return "failure", "error"
71+ case domain.AnnotationWarning:
72+ return "warning", "warning"
73+ }
74+ return "notice", "notice"
75+}
76+
77+// annotationWhere is the "file:line" of an annotation, and a link to the file
78+// at the commit when the path is a path of the repository.
79+func annotationWhere(repo, sha string, a domain.CIAnnotation) (text, href string) {
80+ if a.File == "" {
81+ return "", ""
82+ }
83+ text = visibleText(a.File)
84+ if a.StartLine > 0 {
85+ text += ":" + strconv.Itoa(int(a.StartLine))
86+ if a.EndLine > a.StartLine {
87+ text += "-" + strconv.Itoa(int(a.EndLine))
88+ }
89+ }
90+ if !repoPath(a.File) {
91+ return text, ""
92+ }
93+ href = repoHref(repo, "blob", sha, a.File)
94+ if a.StartLine > 0 {
95+ href += "#L" + strconv.Itoa(int(a.StartLine))
96+ if a.EndLine > a.StartLine {
97+ href += "-L" + strconv.Itoa(int(a.EndLine))
98+ }
99+ }
100+ return text, href
101+}
102+
103+// repoPath reports whether a path of an annotation is a clean path inside the
104+// repository: relative, without "." or ".." segments, backslashes or control
105+// characters.
106+func repoPath(p string) bool {
107+ if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00\r\n\t") || path.Clean(p) != p {
108+ return false
109+ }
110+ return !strings.HasPrefix(p, "../") && p != ".." && p != "."
111+}
112+
113+// annotationViews builds the views of annotations, in their order. jobs maps a
114+// job id to what is known of it; an annotation of an unknown job is skipped.
115+func annotationViews(repo, sha string, list ports.AnnotationList, jobs map[string]jobInfo) []AnnotationView {
116+ out := make([]AnnotationView, 0, len(list.Annotations))
117+ for _, ja := range list.Annotations {
118+ job, ok := jobs[ja.JobID]
119+ if !ok {
120+ continue
121+ }
122+ a := ja.Annotation
123+ v := AnnotationView{
124+ Job: job.name, JobHref: job.href, Title: visibleText(a.Title), Message: visibleText(a.Message),
125+ }
126+ v.Kind, v.Level = annotationKind(a.Level)
127+ if st, ok := job.steps[a.Step]; ok {
128+ v.Step = st.name
129+ if st.end > st.start && job.href != "" {
130+ v.StepHref = job.href + "#L" + strconv.FormatInt(st.start, 10)
131+ }
132+ }
133+ if v.Step == "" {
134+ v.Step = "step " + strconv.Itoa(int(a.Step))
135+ }
136+ v.Where, v.WhereHref = annotationWhere(repo, sha, a)
137+ out = append(out, v)
138+ }
139+ return out
140+}
141+
142+// SummaryView is the summary of one step, rendered.
143+type SummaryView struct {
144+ Step string
145+ HTML template.HTML
146+ // Note replaces the content when it is not shown.
147+ Note string
148+}
149+
150+// JobSummaries are the summaries of one job.
151+type JobSummaries struct {
152+ Job, JobHref string
153+ Steps []SummaryView
154+ // More says that the job has more summaries than the 20 shown.
155+ More bool
156+}
157+
158+// jobSummaries reads and renders the summaries of the jobs of a run, in the
159+// order of the jobs, within budget bytes of Markdown. The budget goes down as
160+// summaries are read and is handed to the use case, which sizes the files
161+// before it reads them: a page view reads at most budget bytes. Once it is used
162+// up no further job is asked, and more is true: some summaries are not shown.
163+func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool, budget int64) (out []JobSummaries, more bool, err error) {
164+ for _, j := range jobs {
165+ if j.Phase != domain.CIPhaseRunning && !j.Ended() {
166+ continue // no step has run
167+ }
168+ if budget <= 0 {
169+ return out, true, nil
170+ }
171+ res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt, budget)
172+ if err != nil {
173+ return nil, false, err
174+ }
175+ if len(res.Summaries) == 0 {
176+ continue
177+ }
178+ info := jobInfoOf(d.name(), run.ID, j, linkJobs)
179+ js := JobSummaries{Job: info.name, JobHref: info.href, More: res.Truncated}
180+ for _, sm := range res.Summaries {
181+ name := "step " + strconv.Itoa(int(sm.Step))
182+ if st, ok := info.steps[sm.Step]; ok && st.name != "" {
183+ name = st.name
184+ }
185+ v := SummaryView{Step: name}
186+ if sm.Omitted {
187+ v.Note = "this summary is not shown: the summaries of the page are over " + humanSize(maxSummaryPageBytes) + "."
188+ more = true
189+ budget = 0 // everything after an omitted summary is omitted too
190+ js.Steps = append(js.Steps, v)
191+ continue
192+ }
193+ budget -= int64(len(sm.Markdown))
194+ prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-"
195+ h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix)
196+ if err != nil {
197+ v.Note = "this summary cannot be shown."
198+ } else {
199+ v.HTML = h
200+ }
201+ js.Steps = append(js.Steps, v)
202+ }
203+ out = append(out, js)
204+ }
205+ return out, more, nil
206+}
207+
208+// jobRunSha is the commit of the run a job belongs to, for the live fragments of
209+// its page. A job id that is not one, or a run that cannot be read, is not found.
210+func (s *Server) jobRunSha(ctx context.Context, repo, jobID string) (string, error) {
211+ name, err := domain.ParseRepoName(repo)
212+ if err != nil {
213+ return "", domain.ErrNotFound
214+ }
215+ runID, _, _, ok := domain.ParseJobID(jobID)
216+ if !ok {
217+ return "", domain.ErrNotFound
218+ }
219+ d, err := s.cfg.Runs.GetRun(ctx, principal(ctx), name, runID, 0)
220+ if err != nil {
221+ return "", err
222+ }
223+ return d.Run.Sha, nil
224+}
225+
226+// runAnnotations reads the annotations of a run attempt and builds their views.
227+func (s *Server) runAnnotations(ctx context.Context, d *repoData, run domain.CIRun, attempt int32, jobs []ports.JobView, linkJobs bool) (annotations []AnnotationView, truncated bool, err error) {
228+ list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, run.ID, attempt, "")
229+ if err != nil {
230+ return nil, false, err
231+ }
232+ infos := make(map[string]jobInfo, len(jobs))
233+ for _, j := range jobs {
234+ infos[j.ID] = jobInfoOf(d.name(), run.ID, j, linkJobs)
235+ }
236+ return annotationViews(d.name(), run.Sha, list, infos), list.Truncated, nil
237+}
238+
239+// jobAnnotations reads the annotations of one job and builds their views,
240+// grouped by step.
241+func (s *Server) jobAnnotations(ctx context.Context, d *repoData, runID int64, sha string, view ports.JobView, attempt int32) ([]AnnotationView, bool, error) {
242+ list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, runID, attempt, view.ID)
243+ if err != nil {
244+ return nil, false, err
245+ }
246+ views := annotationViews(d.name(), sha, list, map[string]jobInfo{view.ID: jobInfoOf(d.name(), runID, view, true)})
247+ for i := range views {
248+ views[i].StepHead = i == 0 || views[i-1].Step != views[i].Step
249+ }
250+ return views, list.Truncated, nil
251+}
A internal/adapters/web/actions_results_test.go +237 -0
@@ -0,0 +1,237 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+// AC6: a summary is untrusted Markdown. The renderer is the one of the READMEs:
22+// raw HTML is dropped and a javascript: link loses its link.
23+func TestSummariesAreRenderedSafely(t *testing.T) {
24+ t.Parallel()
25+ const jobID = "1-0-0"
26+ repo := repoName(t, "proj")
27+ job := ports.JobView{CIJob: domain.CIJob{
28+ ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build <script>",
29+ Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
30+ Steps: []domain.CIStep{{Number: 2, Name: "step <b>two</b>", State: domain.CIStepCompleted, Conclusion: domain.CISuccess}},
31+ }}
32+ md := strings.Join([]string{
33+ "# Title",
34+ "<script>alert(1)</script>",
35+ "",
36+ "[x](javascript:alert(1))",
37+ "",
38+ "[ok](https://example.org/)",
39+ "",
40+ "<a href=\"javascript:alert(2)\" onclick=\"alert(3)\">raw</a>",
41+ "",
42+ "[frag](#title)",
43+ }, "\n")
44+
45+ runs := mocks.NewRunService(t)
46+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
47+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
48+ Jobs: []ports.JobView{job}, LatestAttempt: 1,
49+ }, nil)
50+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
51+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, jobID, int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
52+ Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true,
53+ }, nil)
54+ browse := mocks.NewBrowseService(t)
55+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
56+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
57+
58+ rec := httptest.NewRecorder()
59+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil))
60+ require.Equal(t, http.StatusOK, rec.Code)
61+ body := rec.Body.String()
62+
63+ assert.Contains(t, body, "<h1", "Markdown is rendered")
64+ assert.Contains(t, body, `href="https://example.org/"`)
65+ assert.Contains(t, body, "second")
66+ assert.Contains(t, body, "step &lt;b&gt;two&lt;/b&gt;", "the name of a step is text")
67+ assert.Contains(t, body, "step 7", "a step the job does not list is named by its number")
68+ assert.Contains(t, body, "build &lt;script&gt;", "the job name is text")
69+ assert.Contains(t, body, "more summaries than the first 20")
70+ assert.Contains(t, body, `href="#md-s1-0-0-2-title"`, "fragments point at the headings of their own summary")
71+ assert.Contains(t, body, `id="md-s1-0-0-2-title"`)
72+
73+ assert.NotContains(t, body, "<script>alert")
74+ assert.NotContains(t, body, "javascript:")
75+ assert.NotContains(t, body, "onclick")
76+ assert.NotContains(t, body, `<a href="javascript`)
77+ assert.NotContains(t, body, "alert(1)</", "no script text left as markup")
78+ assert.Contains(t, body, ">x</", "the text of the dropped link stays")
79+}
80+
81+func TestAnnotationViews(t *testing.T) {
82+ t.Parallel()
83+ sha := strings.Repeat("b", 40)
84+ jobs := map[string]jobInfo{"1-0-0": {name: "build", href: "/proj/actions/runs/1/job/1-0-0", steps: map[int32]stepInfo{
85+ 2: {name: "Run vet", start: 4, end: 9},
86+ 3: {name: "no lines", start: 9, end: 9},
87+ }}}
88+ list := ports.AnnotationList{Annotations: []ports.JobAnnotation{
89+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationFailure, Title: "T", Message: "m", File: "pkg/a b.go", StartLine: 4, EndLine: 6, Step: 2}},
90+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "w", File: "x.go", StartLine: 1, EndLine: 1, Step: 3}},
91+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "n", Step: 40}},
92+ {JobID: "gone", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "x", Step: 1}},
93+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "p", File: "../../etc/passwd", StartLine: 2, Step: 2}},
94+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "a", File: "/etc/passwd", Step: 2}},
95+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "b", File: `dir\file.go`, Step: 2}},
96+ {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "c", File: "a/./b.go", Step: 2}},
97+ }}
98+ v := annotationViews("proj", sha, list, jobs)
99+ require.Len(t, v, 7, "an annotation of a job the page does not know is skipped")
100+
101+ assert.Equal(t, "failure", v[0].Kind)
102+ assert.Equal(t, "error", v[0].Level)
103+ assert.Equal(t, "Run vet", v[0].Step)
104+ assert.Equal(t, "/proj/actions/runs/1/job/1-0-0#L4", v[0].StepHref)
105+ assert.Equal(t, "pkg/a b.go:4-6", v[0].Where)
106+ assert.Equal(t, "/proj/blob/"+sha+"/pkg/a%20b.go#L4-L6", v[0].WhereHref)
107+
108+ assert.Equal(t, "x.go:1", v[1].Where, "an end line equal to the start is not a range")
109+ assert.Equal(t, "/proj/blob/"+sha+"/x.go#L1", v[1].WhereHref)
110+ assert.Empty(t, v[1].StepHref, "a step without lines has no anchor")
111+
112+ assert.Equal(t, "step 40", v[2].Step, "a step the job does not list")
113+ assert.Equal(t, "notice", v[2].Level)
114+ assert.Empty(t, v[2].Where)
115+
116+ for i, want := range []string{"../../etc/passwd:2", "/etc/passwd", `dir\file.go`, "a/./b.go"} {
117+ assert.Equal(t, want, v[3+i].Where)
118+ assert.Empty(t, v[3+i].WhereHref, "%q is not a path of the repository", want)
119+ }
120+}
121+
122+func TestRepoPath(t *testing.T) {
123+ t.Parallel()
124+ for _, ok := range []string{"a", "a/b.go", "dir/with space/x", ".github/workflows/ci.yml", "a..b", "é/ü.go"} {
125+ assert.True(t, repoPath(ok), ok)
126+ }
127+ for _, bad := range []string{"", ".", "..", "../a", "a/..", "a/../b", "/a", "a//b", "a/./b", "a/", `a\b`, "a\x00b", "a\nb", "a\tb"} {
128+ assert.False(t, repoPath(bad), "%q", bad)
129+ }
130+}
131+
132+func endedJob(t *testing.T, repo domain.RepoName, id string) ports.JobView {
133+ t.Helper()
134+ return ports.JobView{CIJob: domain.CIJob{
135+ ID: id, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "k", Name: "job " + id,
136+ Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
137+ }}
138+}
139+
140+func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, runs *mocks.RunService) *httptest.ResponseRecorder {
141+ t.Helper()
142+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
143+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
144+ Jobs: jobs, LatestAttempt: 1,
145+ }, nil)
146+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
147+ browse := mocks.NewBrowseService(t)
148+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
149+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: browse, Runs: runs})
150+ rec := httptest.NewRecorder()
151+ srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil))
152+ require.Equal(t, http.StatusOK, rec.Code)
153+ return rec
154+}
155+
156+// GO-001: once the render budget is used up no other job is asked for its
157+// summaries, and the page says that some are not shown.
158+func TestSummariesStopAtTheBudget(t *testing.T) {
159+ t.Parallel()
160+ repo := repoName(t, "proj")
161+ jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")}
162+ runs := mocks.NewRunService(t)
163+ // The first job takes the whole budget; the other two are never asked
164+ // (the mock fails the test on a call it does not expect).
165+ full := strings.Repeat("x", int(maxSummaryPageBytes))
166+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
167+ Summaries: []domain.CIStepSummary{{Step: 1, Markdown: full}},
168+ }, nil).Once()
169+ body := runPageServer(t, repo, jobs, runs).Body.String()
170+ assert.Contains(t, body, "more summaries are not shown")
171+ assert.Contains(t, body, `aria-label="Summary of job 1-0-0"`)
172+ assert.NotContains(t, body, `aria-label="Summary of job 1-1-0"`, "no summary of the other jobs")
173+}
174+
175+// The budget handed to the next job is what is left; a summary the store left
176+// unread is shown as such, and so are the ones after it.
177+func TestSummariesPassWhatIsLeftOfTheBudget(t *testing.T) {
178+ t.Parallel()
179+ repo := repoName(t, "proj")
180+ jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")}
181+ runs := mocks.NewRunService(t)
182+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
183+ Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "hello"}},
184+ }, nil).Once()
185+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-1-0", int32(1), maxSummaryPageBytes-5).Return(ports.SummaryList{
186+ Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "world"}, {Step: 2, Omitted: true}, {Step: 3, Omitted: true}},
187+ }, nil).Once()
188+ body := runPageServer(t, repo, jobs, runs).Body.String()
189+ assert.Contains(t, body, "hello")
190+ assert.Contains(t, body, "world")
191+ assert.Equal(t, 2, strings.Count(body, "this summary is not shown"))
192+ assert.Contains(t, body, "more summaries are not shown")
193+ assert.NotContains(t, body, `aria-label="Summary of job 1-2-0"`, "the third job is not asked once an omission happened")
194+}
195+
196+func TestNoNoteWhenEverythingFits(t *testing.T) {
197+ t.Parallel()
198+ repo := repoName(t, "proj")
199+ runs := mocks.NewRunService(t)
200+ runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
201+ Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "small"}},
202+ }, nil).Once()
203+ body := runPageServer(t, repo, []ports.JobView{endedJob(t, repo, "1-0-0")}, runs).Body.String()
204+ assert.Contains(t, body, "small")
205+ assert.NotContains(t, body, "not shown")
206+}
207+
208+// GO-002: a live job update reads the job and its annotations, never the run;
209+// the commit comes from the stream, and a stream without it has the page fetch
210+// itself again.
211+func TestJobStateDoesNotReadTheRun(t *testing.T) {
212+ t.Parallel()
213+ const jobID = "1-0-0"
214+ repo := repoName(t, "proj")
215+ job := endedJob(t, repo, jobID)
216+ job.Steps = []domain.CIStep{{Number: 1, Name: "s", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, HasLines: true}}
217+ job.Lines = 2
218+ sha := strings.Repeat("c", 40)
219+
220+ runs := mocks.NewRunService(t) // GetRun is not expected
221+ runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil)
222+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{
223+ Annotations: []ports.JobAnnotation{{JobID: jobID, Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 2, Step: 1}}},
224+ }, nil)
225+ browse := mocks.NewBrowseService(t)
226+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
227+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
228+
229+ frags, err := (&liveStream{s: srv, repo: "proj", view: "job", job: jobID, sha: sha}).jobState(context.Background())
230+ require.NoError(t, err)
231+ require.Len(t, frags, 3)
232+ assert.Contains(t, frags[2].html, `href="/proj/blob/`+sha+`/a.go#L2"`, "the annotation links to the commit the stream was set up with")
233+
234+ frags, err = (&liveStream{s: srv, repo: "proj", view: "job", job: jobID}).jobState(context.Background())
235+ require.NoError(t, err)
236+ assert.Equal(t, gap, frags)
237+}
M internal/adapters/web/actions_templ.go +1033 -400 generated file: collapsed
M internal/adapters/web/actions_test.go +1 -0
@@ -38,6 +38,7 @@ func TestLogPagesPassTheClientKey(t *testing.T) {
3838 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
3939 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1,
4040 }, nil).Maybe()
41+ runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{}, nil).Maybe()
4142 var seen []string
4243 runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(
4344 func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error {
M internal/adapters/web/events.go +17 -1
@@ -120,6 +120,22 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
120120 }
121121 }
122122
123+ // The commit of the run of a job page is read once here: the annotations of
124+ // the live job fragments link to files at it, and it never changes, so a
125+ // job update does not read the run again. A run that is not there or not
126+ // readable refuses the stream like a missing repository.
127+ var sha string
128+ if repo != "" && q.Get("view") == "job" && s.cfg.Runs != nil {
129+ cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
130+ var err error
131+ sha, err = s.jobRunSha(cctx, repo, q.Get("job"))
132+ ccancel()
133+ if err != nil {
134+ s.denyEvents(ctx, w, err)
135+ return
136+ }
137+ }
138+
123139 // The watch runs in a goroutine and hands each event to this one through
124140 // an unbuffered channel: a visitor that reads slowly holds the watch in
125141 // its send, falls behind the buffer of the use case, and gets a gap.
@@ -202,7 +218,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
202218 return
203219 }
204220
205- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")}
221+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
206222 handle := func(m recvd) bool {
207223 if m.err != nil {
208224 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
M internal/adapters/web/fragments.go +15 -1
@@ -26,6 +26,9 @@ type liveStream struct {
2626 head string // commits: the commit listed first
2727 n string // mr: the number of the merge request shown; run: the run id
2828 job string // job: the id of the job shown
29+ // sha is the commit of the run of the job shown, read once when the stream
30+ // is set up: the annotations link to files at it, and it never changes.
31+ sha string
2932 }
3033
3134 // renderFragment renders a component to the data of an event.
@@ -478,6 +481,9 @@ func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
478481 // jobState tells the job page that its state or its steps changed: the page
479482 // reloads the part of the log after the last line it holds (ermite.js).
480483 func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
484+ if l.sha == "" {
485+ return gap, nil // the stream was set up without the commit of the run
486+ }
481487 d, err := l.s.loadRepo(ctx, l.repo)
482488 if err != nil {
483489 return nil, err
@@ -496,5 +502,13 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
496502 if err != nil {
497503 return nil, err
498504 }
499- return []fragment{f, steps}, nil
505+ anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0)
506+ if err != nil {
507+ return nil, err
508+ }
509+ notes, err := renderFragment(ctx, "ci-job", JobAnnotations(anns, more))
510+ if err != nil {
511+ return nil, err
512+ }
513+ return []fragment{f, steps, notes}, nil
500514 }
M internal/adapters/web/markdown.go +17 -5
@@ -55,9 +55,16 @@ func newMarkdown() *markdown {
5555 // are where relative links point; dir is the directory of the Markdown file
5656 // in the repository, "" for the root.
5757 func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()}))
58+ return m.renderWithIDs(source, repo, ref, dir, headingIDPrefix)
59+}
60+
61+// renderWithIDs is render with the prefix of the heading ids and of the
62+// #fragment links chosen, so that several documents on one page (the summaries
63+// of a run) do not share ids.
64+func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
65+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
5966 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
60- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source}
67+ pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
6168 pol.apply(doc)
6269
6370 var buf bytes.Buffer
@@ -70,10 +77,13 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
7077 }
7178
7279 // prefixedIDs prefixes the heading ids that goldmark generates.
73-type prefixedIDs struct{ inner parser.IDs }
80+type prefixedIDs struct {
81+ prefix string
82+ inner parser.IDs
83+}
7484
7585 func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
76- return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...)
86+ return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
7787 }
7888
7989 func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
@@ -119,6 +129,8 @@ func plainText(n ast.Node, source []byte) string {
119129 type linkPolicy struct {
120130 repo, ref, dir string
121131 source []byte
132+ // idPrefix is the prefix of the heading ids that #fragment links point at.
133+ idPrefix string
122134 }
123135
124136 func (p linkPolicy) apply(doc ast.Node) {
@@ -202,7 +214,7 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
202214 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
203215 return "", dropped
204216 }
205- return "#" + headingIDPrefix + frag, internal
217+ return "#" + p.idPrefix + frag, internal
206218 }
207219 if strings.HasPrefix(dest, "//") {
208220 return "", dropped
M internal/adapters/web/static/css/65-ci.css +29 -0
@@ -37,6 +37,35 @@
3737 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
3838 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
3939
40+ /* annotations and summaries of a run */
41+ .annots { margin: 0 0 var(--s4); }
42+ .annots h3, .summaries h3 { display: flex; align-items: baseline; gap: var(--s2); margin: 0 0 var(--s2); font-size: var(--fs-s); }
43+ .annot-list { display: grid; gap: var(--s2); padding: 0; margin: 0; list-style: none; }
44+ .annot {
45+ display: flex; gap: var(--s2); align-items: flex-start; padding: var(--s2) var(--s3);
46+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--border-strong); border-radius: var(--r-s);
47+ font-size: var(--fs-s);
48+ }
49+ .annot-failure { border-left-color: var(--red); }
50+ .annot-warning { border-left-style: dashed; border-left-color: var(--ink-dim); }
51+ .annot-notice { border-left-color: var(--teal); }
52+ .annot p { margin: 0; }
53+ .annot-step { flex-basis: 100%; font-weight: 700; }
54+ .annot:has(.annot-step) { flex-wrap: wrap; }
55+ .annot-body { min-width: 0; }
56+ .annot-head { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: baseline; }
57+ .annot-level { font-weight: 700; }
58+ .annot-msg { white-space: pre-wrap; overflow-wrap: anywhere; }
59+ .annot-where { color: var(--ink-dim); font-family: var(--mono); overflow-wrap: anywhere; }
60+ .an { display: inline-block; flex: none; width: 14px; height: 14px; margin-top: 2px; border: 2px solid var(--ink-dim); box-sizing: border-box; }
61+ .an-failure { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); }
62+ .an-warning { border-radius: 50% 50% 0 0; border-style: dashed; }
63+ .an-notice { border-radius: 50%; border-color: var(--teal); }
64+ .summaries { margin: var(--s5) 0 0; }
65+ .summary { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
66+ .summary + .summary { margin-top: var(--s3); }
67+ .summary-step { margin: 0 0 var(--s2); font-size: var(--fs-s); color: var(--ink-dim); }
68+
4069 .joblog { margin-top: var(--s4); }
4170 .log {
4271 margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto;
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/ci_results_test.go +553 -0
@@ -0,0 +1,553 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+// CI-2 results (phase E2): annotations and step summaries, from the runner's
20+// calls to the API, the pages and the CLI.
21+
22+const resultsWorkflow = `name: ci
23+on:
24+ push:
25+ branches: [main]
26+jobs:
27+ build:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - run: echo hello
31+ lint:
32+ runs-on: ubuntu-latest
33+ steps:
34+ - run: echo lint
35+`
36+
37+func ann(level pb.AnnotationLevel, step int32, msg string) *pb.Annotation {
38+ return &pb.Annotation{Level: level, Message: msg, StepNumber: step}
39+}
40+
41+func (r *fakeRunner) update(j *pb.Job, steps []*pb.StepUpdate, anns ...*pb.Annotation) error {
42+ _, err := r.jobAPI(j).runner.UpdateJob(r.c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: steps, Annotations: anns}))
43+ return err
44+}
45+
46+func (r *fakeRunner) summary(j *pb.Job, step int32, md string) error {
47+ _, err := r.jobAPI(j).runner.UploadSummary(r.c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: j.GetJobId(), StepNumber: step, Markdown: md}))
48+ return err
49+}
50+
51+func (r *fakeRunner) heartbeat(j *pb.Job) {
52+ r.c.t.Helper()
53+ _, err := r.jobAPI(j).runner.Heartbeat(r.c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
54+ require.NoError(r.c.t, err)
55+}
56+
57+func (c *ciHarness) annotations(runID int64, jobID string) *pb.ListAnnotationsResponse {
58+ c.t.Helper()
59+ res, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: jobID}))
60+ require.NoError(c.t, err)
61+ return res.Msg
62+}
63+
64+func (c *ciHarness) summaries(jobID string) *pb.GetStepSummariesResponse {
65+ c.t.Helper()
66+ res, err := c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: jobID}))
67+ require.NoError(c.t, err)
68+ return res.Msg
69+}
70+
71+func code(err error) connect.Code { return connect.CodeOf(err) }
72+
73+func TestCIAnnotationsAndSummariesOverTheAPI(t *testing.T) {
74+ c := newCIHarness(t, nil)
75+ runner := c.registerRunner("alpha", "ubuntu-latest")
76+ c.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
77+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
78+ runID := c.runs()[0].GetId()
79+
80+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
81+ require.NoError(t, err)
82+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.annotations")
83+ assert.Contains(t, ver.Msg.GetFeatures(), "ci.summaries")
84+
85+ a, b := runner.await(), runner.await()
86+ runner.heartbeat(a)
87+ runner.heartbeat(b)
88+ start := now()
89+ running := []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start}}
90+ require.NoError(t, runner.update(a, running))
91+ done := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()}
92+
93+ notice, warning, failure := pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE
94+ t.Run("an annotation of a step that UpdateJob never named is stored", func(t *testing.T) {
95+ full := &pb.Annotation{Level: failure, Title: "Vet", Message: "unused variable", File: "main.go", StartLine: 3, EndLine: 4, StartColumn: 2, EndColumn: 8, StepNumber: 7}
96+ require.NoError(t, runner.update(a, nil, full, ann(notice, 7, "second")))
97+ got := c.annotations(runID, a.GetJobId())
98+ require.Len(t, got.GetAnnotations(), 2)
99+ assert.Equal(t, a.GetJobId(), got.GetAnnotations()[0].GetJobId())
100+ assert.EqualValues(t, full.GetStartLine(), got.GetAnnotations()[0].GetAnnotation().GetStartLine())
101+ assert.Equal(t, "Vet", got.GetAnnotations()[0].GetAnnotation().GetTitle())
102+ assert.Equal(t, "second", got.GetAnnotations()[1].GetAnnotation().GetMessage(), "in the order the step produced them")
103+ assert.False(t, got.GetTruncated())
104+ })
105+
106+ t.Run("replacement per step, a retry changes nothing, a step absent is kept", func(t *testing.T) {
107+ require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
108+ require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
109+ require.NoError(t, runner.update(a, nil, ann(notice, 7, "only one now")))
110+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
111+ require.Len(t, got, 2, "step 3 is kept, step 7 holds one annotation")
112+ assert.EqualValues(t, 3, got[0].GetAnnotation().GetStepNumber(), "ordered by step")
113+ assert.Equal(t, "only one now", got[1].GetAnnotation().GetMessage())
114+ })
115+
116+ t.Run("a COMPLETED step can still have its annotations replaced", func(t *testing.T) {
117+ require.NoError(t, runner.update(a, []*pb.StepUpdate{done}, ann(notice, 1, "before")))
118+ require.NoError(t, runner.update(a, nil, ann(failure, 1, "after")))
119+ require.NoError(t, runner.update(a, []*pb.StepUpdate{done}), "the same step again changes nothing")
120+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
121+ assert.Equal(t, "after", got[0].GetAnnotation().GetMessage())
122+ assert.Equal(t, failure, got[0].GetAnnotation().GetLevel())
123+ // The rule that a completed step cannot change still holds for the step.
124+ changed := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: done.GetCompletedAt()}
125+ assert.Equal(t, connect.CodeFailedPrecondition, code(runner.update(a, []*pb.StepUpdate{changed})))
126+ })
127+
128+ t.Run("an empty list for a step is not a request: only a step present is replaced", func(t *testing.T) {
129+ before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
130+ require.NoError(t, runner.update(a, nil))
131+ assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
132+ })
133+
134+ t.Run("the limits, and a call applied whole or not at all", func(t *testing.T) {
135+ before := c.annotations(runID, a.GetJobId()).GetAnnotations()
136+ many := func(level pb.AnnotationLevel, n int) []*pb.Annotation {
137+ var out []*pb.Annotation
138+ for range n {
139+ out = append(out, ann(level, 9, "m"))
140+ }
141+ return out
142+ }
143+ require.NoError(t, runner.update(a, nil, many(warning, 10)...), "ten of a level is allowed")
144+ require.NoError(t, runner.update(a, nil, append(many(warning, 10), many(failure, 10)...)...), "the limit is per level")
145+ bad := map[string][]*pb.Annotation{
146+ "eleven of a level": many(notice, 11),
147+ "no level": {{Message: "m", StepNumber: 1}},
148+ "no message": {ann(notice, 1, "")},
149+ "message too long": {ann(notice, 1, strings.Repeat("é", 4097))},
150+ "title too long": {{Level: notice, Message: "m", Title: strings.Repeat("t", 256), StepNumber: 1}},
151+ "file too long": {{Level: notice, Message: "m", File: strings.Repeat("f", 1025), StepNumber: 1}},
152+ "step zero": {ann(notice, 0, "m")},
153+ "step beyond 1000": {ann(notice, 1001, "m")},
154+ "end before start": {{Level: notice, Message: "m", StartLine: 5, EndLine: 4, StepNumber: 1}},
155+ "a good one and bad": {ann(notice, 1, "good"), ann(notice, 1, "")},
156+ }
157+ for name, anns := range bad {
158+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, nil, anns...)), name)
159+ }
160+ // Characters, not bytes: 4096 two-byte characters pass.
161+ require.NoError(t, runner.update(a, nil, ann(notice, 11, strings.Repeat("é", 4096))))
162+ require.NoError(t, runner.update(a, nil, &pb.Annotation{Level: notice, Message: "m", Title: strings.Repeat("t", 255), File: strings.Repeat("f", 1024), StepNumber: 11}))
163+ // An invalid call stored nothing: steps 1, 3 and 7 are as they were.
164+ after := c.annotations(runID, a.GetJobId()).GetAnnotations()
165+ require.GreaterOrEqual(t, len(after), len(before))
166+ var step1 []string
167+ for _, x := range after {
168+ if x.GetAnnotation().GetStepNumber() == 1 {
169+ step1 = append(step1, x.GetAnnotation().GetMessage())
170+ }
171+ }
172+ assert.Equal(t, []string{"after"}, step1)
173+ // A step of a bad call that is valid is not applied either.
174+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, []*pb.StepUpdate{{Number: 5, State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}}, ann(notice, 1, ""))))
175+ for _, s := range c.getRun(runID).GetJobs()[0].GetSteps() {
176+ assert.NotEqual(t, int32(5), s.GetNumber())
177+ }
178+ })
179+
180+ t.Run("summaries: stored, replaced, removed, bounded", func(t *testing.T) {
181+ require.NoError(t, runner.summary(a, 2, "# one"))
182+ require.NoError(t, runner.summary(a, 2, "# one"))
183+ require.NoError(t, runner.summary(a, 2, "# two"))
184+ require.NoError(t, runner.summary(a, 12, "later"))
185+ got := c.summaries(a.GetJobId())
186+ require.Len(t, got.GetSummaries(), 2)
187+ assert.EqualValues(t, 2, got.GetSummaries()[0].GetStepNumber())
188+ assert.Equal(t, "# two", got.GetSummaries()[0].GetMarkdown())
189+ assert.EqualValues(t, 12, got.GetSummaries()[1].GetStepNumber(), "numeric order")
190+ require.NoError(t, runner.summary(a, 12, ""))
191+ assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 1, "an empty markdown removes it")
192+
193+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 0, "x")))
194+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 1001, "x")))
195+ assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 2, strings.Repeat("x", 1<<20+1))))
196+ require.NoError(t, runner.summary(a, 3, strings.Repeat("x", 1<<20)), "exactly 1 MiB is accepted")
197+ assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 2)
198+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "an invalid call changes nothing")
199+ })
200+
201+ t.Run("at most 20 summaries per job, with truncated", func(t *testing.T) {
202+ for i := int32(1); i <= 22; i++ {
203+ require.NoError(t, runner.summary(b, i, "s"))
204+ }
205+ got := c.summaries(b.GetJobId())
206+ assert.Len(t, got.GetSummaries(), 20)
207+ assert.True(t, got.GetTruncated())
208+ assert.EqualValues(t, 20, got.GetSummaries()[19].GetStepNumber())
209+ })
210+
211+ t.Run("the token of another job is refused", func(t *testing.T) {
212+ other := runner.jobAPI(b).runner
213+ _, err := other.UpdateJob(c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: a.GetJobId(), Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
214+ assert.Equal(t, connect.CodePermissionDenied, code(err))
215+ _, err = other.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
216+ assert.Equal(t, connect.CodePermissionDenied, code(err))
217+ _, err = other.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
218+ assert.Equal(t, connect.CodePermissionDenied, code(err))
219+ // No credential, a runner token and a stored token are not job tokens either.
220+ for _, secret := range []string{"", runner.secret, adminToken} {
221+ cl := c.api(secret).runner
222+ _, err = cl.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
223+ assert.Equal(t, connect.CodeUnauthenticated, code(err), secret)
224+ }
225+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown())
226+ })
227+
228+ t.Run("CompleteJob applies annotations with the steps and the outcome", func(t *testing.T) {
229+ steps := []*pb.StepUpdate{
230+ done,
231+ {
232+ Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
233+ Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now(),
234+ },
235+ }
236+ _, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
237+ JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
238+ Annotations: []*pb.Annotation{ann(warning, 2, "from complete"), ann(notice, 1, "step one, replaced")},
239+ }))
240+ require.NoError(t, err)
241+ got := c.annotations(runID, a.GetJobId()).GetAnnotations()
242+ var msgs []string
243+ for _, x := range got {
244+ if s := x.GetAnnotation().GetStepNumber(); s == 1 || s == 2 {
245+ msgs = append(msgs, x.GetAnnotation().GetMessage())
246+ }
247+ }
248+ assert.Equal(t, []string{"step one, replaced", "from complete"}, msgs)
249+
250+ job := c.getRun(runID).GetJobs()[0]
251+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[1].GetConclusion())
252+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, job.GetSteps()[1].GetOutcome(), "continue-on-error: failed, then succeeded")
253+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[0].GetOutcome(), "a runner that sent none: read as the conclusion")
254+ })
255+
256+ t.Run("a job that ended rejects annotations and summaries; a retry of CompleteJob changes nothing", func(t *testing.T) {
257+ before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
258+ err := runner.update(a, nil, ann(notice, 1, "too late"))
259+ require.Equal(t, connect.CodeFailedPrecondition, code(err))
260+ assert.NotEmpty(t, errDetails(err), "with a JobEnded detail")
261+ err = runner.summary(a, 1, "too late")
262+ assert.Equal(t, connect.CodeFailedPrecondition, code(err))
263+ assert.NotEmpty(t, errDetails(err))
264+ res, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
265+ JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "ignored")},
266+ }))
267+ require.NoError(t, err)
268+ assert.False(t, res.Msg.GetApplied())
269+ assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
270+ for _, x := range c.annotations(runID, a.GetJobId()).GetAnnotations() {
271+ assert.NotEqual(t, "too late", x.GetAnnotation().GetMessage())
272+ assert.NotEqual(t, "ignored", x.GetAnnotation().GetMessage())
273+ }
274+ assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "still readable after the end")
275+ })
276+
277+ t.Run("reading: the run, one job, a job that is not of the run, access", func(t *testing.T) {
278+ all := c.annotations(runID, "")
279+ jobs := map[string]int{}
280+ for _, x := range all.GetAnnotations() {
281+ jobs[x.GetJobId()]++
282+ }
283+ assert.Len(t, jobs, 1, "job b has none")
284+ assert.Empty(t, c.annotations(runID, b.GetJobId()).GetAnnotations())
285+
286+ _, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: "99-0-0"}))
287+ assert.Equal(t, connect.CodeNotFound, code(err))
288+ _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, Attempt: 2}))
289+ assert.Equal(t, connect.CodeNotFound, code(err))
290+ _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: 99}))
291+ assert.Equal(t, connect.CodeNotFound, code(err))
292+ _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: "99-0-0"}))
293+ assert.Equal(t, connect.CodeNotFound, code(err))
294+ _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId(), Attempt: 3}))
295+ assert.Equal(t, connect.CodeNotFound, code(err))
296+
297+ // The repository is private: an anonymous caller sees nothing of it, a
298+ // reader sees everything, as for GetRun.
299+ anon := c.api("").run
300+ _, err = anon.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
301+ assert.Equal(t, connect.CodeNotFound, code(err))
302+ _, err = anon.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
303+ assert.Equal(t, connect.CodeNotFound, code(err))
304+ reader := c.api(c.token(scopes(domain.ScopeRepoRead))).run
305+ _, err = reader.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
306+ assert.NoError(t, err)
307+ _, err = reader.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
308+ assert.NoError(t, err)
309+ })
310+
311+ t.Run("the summaries of an ended job stay readable", func(t *testing.T) {
312+ _, err := runner.jobAPI(b).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE}))
313+ require.NoError(t, err)
314+ assert.Len(t, c.summaries(b.GetJobId()).GetSummaries(), 20)
315+ })
316+}
317+
318+// errDetails returns the Connect error details of err.
319+func errDetails(err error) []*connect.ErrorDetail {
320+ var ce *connect.Error
321+ if connect.CodeOf(err) == connect.CodeUnknown {
322+ return nil
323+ }
324+ if e, ok := err.(*connect.Error); ok {
325+ ce = e
326+ }
327+ if ce == nil {
328+ return nil
329+ }
330+ return ce.Details()
331+}
332+
333+// The pages: the Annotations box of the run, the annotations at their step on
334+// the job page, the summaries rendered under the run, with nothing of the
335+// workflow's markup reaching the page as markup.
336+func TestWebAnnotationsAndSummaries(t *testing.T) {
337+ ci := newCIHarness(t, nil)
338+ b := browser{ci.harness}
339+ runner := ci.registerRunner("alpha", "ubuntu-latest")
340+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
341+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
342+ }))
343+ require.NoError(t, err)
344+ sha := ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0], "src/main.go": "package main\n"}, "")
345+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
346+
347+ job := runner.await()
348+ runner.heartbeat(job)
349+ start := now()
350+ steps := []*pb.StepUpdate{
351+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
352+ {Number: 2, Name: "Run <b>vet</b>", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
353+ }
354+ require.NoError(t, runner.update(job, steps))
355+ _, err = runner.jobAPI(job).runner.AppendLog(ci.ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: job.GetJobId(), Step: 2, Lines: []*pb.LogLine{{Time: now(), Text: "vet"}}}))
356+ require.NoError(t, err)
357+ evil := `<img src=x onerror=alert(1)>`
358+ require.NoError(t, runner.update(job, nil,
359+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet <i>failed</i>", Message: "unused " + evil, File: "src/main.go", StartLine: 1, EndLine: 3, StepNumber: 2},
360+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "outside", File: "../../etc/passwd", StartLine: 1, StepNumber: 2},
361+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "plain notice", StepNumber: 1},
362+ ))
363+ require.NoError(t, runner.summary(job, 2, strings.Join([]string{
364+ "# Results",
365+ "",
366+ "<script>alert('summary')</script>",
367+ "",
368+ "[click](javascript:alert(1)) and [ok](https://example.org/x)",
369+ "",
370+ "![pixel](https://evil.example/p.png)",
371+ "",
372+ "<img src=x onerror=alert(2)>",
373+ "",
374+ "| a | b |", "|---|---|", "| 1 | 2 |",
375+ }, "\n")))
376+ _, err = runner.jobAPI(job).runner.CompleteJob(ci.ctx, connect.NewRequest(&pb.CompleteJobRequest{
377+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
378+ }))
379+ require.NoError(t, err)
380+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
381+
382+ run := b.get("/proj/actions/runs/1", nil)
383+ require.Equal(t, 200, run.status, run.body)
384+ assert.Contains(t, run.body, "Annotations")
385+ assert.Contains(t, run.body, `aria-label="error"`, "the level has a symbol and a word")
386+ assert.Contains(t, run.body, `aria-label="warning"`)
387+ assert.Contains(t, run.body, "Vet &lt;i&gt;failed&lt;/i&gt;", "a title is text")
388+ assert.Contains(t, run.body, "unused &lt;img src=x onerror=alert(1)&gt;")
389+ assert.NotContains(t, run.body, evil)
390+ assert.Contains(t, run.body, "Run &lt;b&gt;vet&lt;/b&gt;", "the step name is text")
391+ assert.Contains(t, run.body, `href="/proj/blob/`+sha+`/src/main.go#L1-L3"`, "file:line links to the file at the commit")
392+ assert.Contains(t, run.body, "src/main.go:1-3")
393+ assert.Contains(t, run.body, "../../etc/passwd:1")
394+ assert.NotContains(t, run.body, `href="/proj/blob/`+sha+`/../`, "a path that leaves the repository is not a link")
395+ assert.NotContains(t, run.body, `etc/passwd:1</a>`)
396+ assert.Contains(t, run.body, `href="/proj/actions/runs/1/job/`+job.GetJobId()+`"`, "the job of the annotation")
397+
398+ // The summary is rendered by the README renderer: markup dropped.
399+ assert.Contains(t, run.body, "<h1", "Markdown is rendered")
400+ assert.Contains(t, run.body, "Results")
401+ assert.Contains(t, run.body, "<table>")
402+ assert.Contains(t, run.body, `href="https://example.org/x"`)
403+ assert.Contains(t, run.body, `rel="nofollow noopener noreferrer"`)
404+ assert.NotContains(t, run.body, "alert('summary')")
405+ assert.NotContains(t, run.body, "<script>alert")
406+ assert.NotContains(t, run.body, "javascript:")
407+ assert.NotContains(t, run.body, "onerror=alert(2)")
408+ assert.NotContains(t, run.body, `<img src=x`)
409+ assert.NotContains(t, run.body, "evil.example/p.png", "images are never loaded")
410+ assert.Contains(t, run.body, `<span class="alt">[pixel]</span>`)
411+ assert.Contains(t, run.body, `click`, "the text of a dropped link stays")
412+
413+ // The job page: each annotation at its step, the steps linking to the log.
414+ jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
415+ require.Equal(t, 200, jobPage.status, jobPage.body)
416+ assert.Contains(t, jobPage.body, `id="job-annotations"`)
417+ assert.Contains(t, jobPage.body, "plain notice")
418+ assert.Contains(t, jobPage.body, "unused &lt;img src=x onerror=alert(1)&gt;")
419+ assert.NotContains(t, jobPage.body, evil)
420+ assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused &lt;img"), "by step")
421+
422+}
423+
424+// The job page follows the annotations live: a report of the runner replaces the
425+// annotations element, and the stream still never carries the log.
426+func TestWebJobAnnotationsAreLive(t *testing.T) {
427+ ci := newCIHarness(t, nil)
428+ runner := ci.registerRunner("alpha", "ubuntu-latest")
429+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
430+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
431+ }))
432+ require.NoError(t, err)
433+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
434+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
435+ job := runner.await()
436+ stream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
437+ runner.heartbeat(job)
438+ require.NoError(t, runner.update(job, []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}},
439+ ann(pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, 1, "live <b>warning</b>")))
440+ deadline := time.After(10 * time.Second)
441+ for {
442+ select {
443+ case ev := <-stream.events:
444+ if ev.name == "ci-job" && strings.Contains(ev.data, `id="job-annotations"`) && strings.Contains(ev.data, "live &lt;b&gt;warning&lt;/b&gt;") {
445+ assert.NotContains(t, ev.data, "<b>warning</b>")
446+ return
447+ }
448+ case <-deadline:
449+ t.Fatal("the annotation did not reach the stream of the job")
450+ }
451+ }
452+}
453+
454+// A run that is not over has no summary block yet, and a run without result has
455+// neither box.
456+func TestWebRunWithoutAnnotationsOrSummaries(t *testing.T) {
457+ ci := newCIHarness(t, nil)
458+ b := browser{ci.harness}
459+ runner := ci.registerRunner("alpha", "ubuntu-latest")
460+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
461+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
462+ }))
463+ require.NoError(t, err)
464+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
465+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
466+ queued := b.get("/proj/actions/runs/1", nil)
467+ require.Equal(t, 200, queued.status)
468+ assert.NotContains(t, queued.body, "Annotations")
469+
470+ job := runner.await()
471+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello")
472+ done := b.get("/proj/actions/runs/1", nil)
473+ require.Equal(t, 200, done.status)
474+ assert.NotContains(t, done.body, "Annotations")
475+ assert.NotContains(t, done.body, `class="summaries"`)
476+ page := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
477+ require.Equal(t, 200, page.status)
478+ assert.Contains(t, page.body, `id="job-annotations"`, "the slot the live updates fill")
479+ assert.NotContains(t, page.body, "Annotations <")
480+}
481+
482+// `run view` prints the ANNOTATIONS section and `--summary` the Markdown.
483+func TestCLIRunViewAnnotationsAndSummary(t *testing.T) {
484+ ci := newCIHarness(t, nil)
485+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
486+ runner := ci.registerRunner("alpha", "ubuntu-latest")
487+ ci.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
488+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
489+
490+ // A run without annotation prints none.
491+ a, b := runner.await(), runner.await()
492+ runner.heartbeat(a)
493+ runner.heartbeat(b)
494+ plain := h.cli(t, adminToken, "run", "view", "proj", "1")
495+ require.NoError(t, plain.err, plain.stderr)
496+ assert.NotContains(t, plain.stdout, "ANNOTATIONS")
497+ none := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
498+ require.NoError(t, none.err, none.stderr)
499+ assert.Contains(t, none.stderr, "no job summary")
500+
501+ start := now()
502+ steps := []*pb.StepUpdate{
503+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
504+ {Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now()},
505+ }
506+ require.NoError(t, runner.update(a, steps,
507+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet", Message: "unused\x1b[31m variable", File: "main.go", StartLine: 3, EndLine: 5, StepNumber: 2},
508+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "careful", StepNumber: 2},
509+ &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "fyi", File: "README.md", StepNumber: 1},
510+ ))
511+ require.NoError(t, runner.summary(a, 2, "# Hello\n\nfrom a\x1b[2J step\n"))
512+ require.NoError(t, runner.summary(b, 2, "from b"))
513+
514+ view := h.cli(t, adminToken, "run", "view", "proj", "1")
515+ require.NoError(t, view.err, view.stderr)
516+ assert.Contains(t, view.stdout, "\nANNOTATIONS\n")
517+ assert.Contains(t, view.stdout, "LEVEL")
518+ for _, want := range []string{"error", "Vet: unused", "main.go:3-5", "warning", "careful", "notice", "fyi", "README.md", "build"} {
519+ assert.Contains(t, view.stdout, want)
520+ }
521+ assert.NotContains(t, view.stdout, "\x1b", "control characters of the workflow never reach the terminal")
522+ assert.Less(t, strings.Index(view.stdout, "Set up job"), strings.Index(view.stdout, "ANNOTATIONS"), "after the jobs")
523+ // Ordered by job, step, production order: notice (step 1) before the two of step 2.
524+ assert.Less(t, strings.Index(view.stdout, "fyi"), strings.Index(view.stdout, "Vet: unused"))
525+ assert.Less(t, strings.Index(view.stdout, "Vet: unused"), strings.Index(view.stdout, "careful"))
526+
527+ js := h.cli(t, adminToken, "run", "view", "proj", "1", "--json")
528+ require.NoError(t, js.err, js.stderr)
529+ d := decode[jsonRunDetail](t, js.stdout)
530+ require.Len(t, d.Annotations, 3)
531+ assert.Equal(t, jsonAnnotation{
532+ Job: "build", JobID: a.GetJobId(), Step: 1, Level: "notice", Message: "fyi", File: "README.md",
533+ }, d.Annotations[0])
534+ assert.Equal(t, "failure", d.Annotations[1].Level, "the API's name in JSON")
535+ assert.EqualValues(t, 5, d.Annotations[1].EndLine)
536+ assert.False(t, d.AnnotationsTruncated)
537+ assert.Equal(t, "failure", d.Jobs[0].Steps[1].Outcome)
538+ assert.Equal(t, "success", d.Jobs[0].Steps[1].Conclusion)
539+ assert.Equal(t, "success", d.Jobs[0].Steps[0].Outcome, "none stored: the conclusion")
540+
541+ // --summary: raw Markdown, a header per job and step when there are several.
542+ sum := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
543+ require.NoError(t, sum.err, sum.stderr)
544+ assert.Contains(t, sum.stdout, "== build: Run make ==\n# Hello\n")
545+ assert.Contains(t, sum.stdout, "== lint: step 2 ==\nfrom b\n")
546+ assert.NotContains(t, sum.stdout, "\x1b")
547+ one := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "lint")
548+ require.NoError(t, one.err, one.stderr)
549+ assert.Equal(t, "from b\n", one.stdout, "a single summary is printed as it is")
550+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--log").err)
551+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--json").err)
552+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "nope").err)
553+}
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
M internal/app/cli_run.go +238 -15
@@ -40,10 +40,13 @@ type jsonRun struct {
4040 }
4141
4242 type jsonStep struct {
43- Number int32 `json:"number"`
44- Name string `json:"name"`
45- State string `json:"state"`
46- Conclusion string `json:"conclusion"`
43+ Number int32 `json:"number"`
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Conclusion string `json:"conclusion"`
47+ // Outcome is the result before continue-on-error; for a step stored without
48+ // one it is the conclusion.
49+ Outcome string `json:"outcome"`
4750 StartedAt *string `json:"started_at"`
4851 CompletedAt *string `json:"completed_at"`
4952 LogStart int64 `json:"log_start"`
@@ -67,10 +70,28 @@ type jsonRunJob struct {
6770 CompletedAt *string `json:"completed_at"`
6871 }
6972
73+// jsonAnnotation is an annotation of a run, with the job and the step it belongs to.
74+type jsonAnnotation struct {
75+ Job string `json:"job"`
76+ JobID string `json:"job_id"`
77+ Step int32 `json:"step"`
78+ Level string `json:"level"`
79+ Title string `json:"title"`
80+ Message string `json:"message"`
81+ File string `json:"file"`
82+ StartLine int32 `json:"start_line"`
83+ EndLine int32 `json:"end_line"`
84+ StartColumn int32 `json:"start_column"`
85+ EndColumn int32 `json:"end_column"`
86+}
87+
7088 type jsonRunDetail struct {
7189 jsonRun
72- LatestAttempt int32 `json:"latest_attempt"`
73- Jobs []jsonRunJob `json:"jobs"`
90+ LatestAttempt int32 `json:"latest_attempt"`
91+ Jobs []jsonRunJob `json:"jobs"`
92+ Annotations []jsonAnnotation `json:"annotations"`
93+ // AnnotationsTruncated is true when the server holds more annotations than it returned.
94+ AnnotationsTruncated bool `json:"annotations_truncated"`
7495 }
7596
7697 const jsonRunHelp = `
@@ -79,7 +100,9 @@ ref, sha, actor, status (queued, in_progress, completed), conclusion (success, f
79100 skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
80101 started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
81102 jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
82-with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
103+with number, name, state, conclusion, outcome (the result before continue-on-error), log_start and log_end,
104+started_at, completed_at), annotations (job, job_id, step, level notice|warning|failure, title, message,
105+file, start_line, end_line, start_column, end_column) and annotations_truncated.`
83106
84107 func statusText(s ermite.CiStatus) string {
85108 return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
@@ -111,6 +134,15 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
111134 return statusText(status)
112135 }
113136
137+// stepOutcomeText is the outcome of a step; a completed step stored without one
138+// has the outcome of its conclusion (Step.outcome).
139+func stepOutcomeText(s *ermite.Step) string {
140+ if s.GetOutcome() == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED && s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
141+ return conclusionText(s.GetConclusion())
142+ }
143+ return conclusionText(s.GetOutcome())
144+}
145+
114146 func toJSONRun(r *ermite.Run) jsonRun {
115147 return jsonRun{
116148 ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
@@ -125,7 +157,7 @@ func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
125157 for _, s := range j.GetSteps() {
126158 steps = append(steps, jsonStep{
127159 Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
128- StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
160+ Outcome: stepOutcomeText(s), StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
129161 })
130162 }
131163 labels, needs := j.GetLabels(), j.GetNeeds()
@@ -254,21 +286,27 @@ func parseStatusFlag(s string) (ermite.CiStatus, error) {
254286
255287 func newRunViewCmd(env *cliEnv) *cobra.Command {
256288 var attempt int32
257- var logFlag bool
289+ var logFlag, summaryFlag bool
258290 var jobFlag string
259291 cmd := &cobra.Command{
260292 Use: "view <repo> <run id>",
261- Short: "Show a run with its jobs and steps, or the log of a job",
262- Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
293+ Short: "Show a run with its jobs, steps and annotations, or the log or summaries of its jobs",
294+ Long: "Show a run: its state, its jobs and their steps, then its ANNOTATIONS (the notices, warnings and\n" +
295+ "errors that the steps produced, with their job and file:line). --attempt reads an earlier attempt.\n" +
263296 "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
264- "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
297+ "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing.\n" +
298+ "With --summary, print the job summaries (the Markdown the steps wrote to GITHUB_STEP_SUMMARY) of\n" +
299+ "the run's jobs, or of the --job, as Markdown, not rendered." + jsonRunHelp,
265300 Args: cobra.ExactArgs(2),
266301 ValidArgsFunction: completeRepoNames(env),
267302 }
268303 cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
269304 cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
270- cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
305+ cmd.Flags().BoolVar(&summaryFlag, "summary", false, "print the job summaries (Markdown) instead of the summary of the run")
306+ cmd.Flags().StringVar(&jobFlag, "job", "", "with --log or --summary, only this job (its id or its name)")
271307 asJSON := addJSONFlag(cmd)
308+ cmd.MarkFlagsMutuallyExclusive("log", "summary")
309+ cmd.MarkFlagsMutuallyExclusive("json", "summary")
272310 cmd.RunE = func(cmd *cobra.Command, args []string) error {
273311 id, err := parseRunID(args[1])
274312 if err != nil {
@@ -286,14 +324,30 @@ func newRunViewCmd(env *cliEnv) *cobra.Command {
286324 if logFlag {
287325 return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
288326 }
327+ if summaryFlag {
328+ return printRunSummaries(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr(), c, args[0], res.Msg, jobFlag)
329+ }
330+ anns, err := runAnnotations(ctx, c, args[0], res.Msg)
331+ if err != nil {
332+ return err
333+ }
289334 if *asJSON {
290- d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
335+ d := jsonRunDetail{
336+ jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{},
337+ Annotations: []jsonAnnotation{}, AnnotationsTruncated: anns.truncated,
338+ }
291339 for _, j := range res.Msg.GetJobs() {
292340 d.Jobs = append(d.Jobs, toJSONRunJob(j))
293341 }
342+ for _, a := range anns.items {
343+ d.Annotations = append(d.Annotations, toJSONAnnotation(a, anns.jobs))
344+ }
294345 return writeJSON(cmd.OutOrStdout(), d)
295346 }
296- return printRun(cmd.OutOrStdout(), res.Msg)
347+ if err := printRun(cmd.OutOrStdout(), res.Msg); err != nil {
348+ return err
349+ }
350+ return printAnnotations(cmd.OutOrStdout(), anns)
297351 }
298352 return cmd
299353 }
@@ -340,6 +394,175 @@ func printRun(w io.Writer, r *ermite.GetRunResponse) error {
340394 return nil
341395 }
342396
397+// runNotes are the annotations of a run as read from the server.
398+type runNotes struct {
399+ items []*ermite.JobAnnotation
400+ truncated bool
401+ // jobs maps a job id to the job.
402+ jobs map[string]*ermite.RunJob
403+}
404+
405+// runAnnotations reads the annotations of the attempt of a run that was read.
406+// A server without the feature has none: it answers unimplemented, which is
407+// not an error here.
408+func runAnnotations(ctx context.Context, c *client.Client, repo string, r *ermite.GetRunResponse) (runNotes, error) {
409+ out := runNotes{jobs: map[string]*ermite.RunJob{}}
410+ for _, j := range r.GetJobs() {
411+ out.jobs[j.GetId()] = j
412+ }
413+ res, err := c.Runs().ListAnnotations(ctx, connect.NewRequest(&ermite.ListAnnotationsRequest{
414+ Repo: repo, RunId: r.GetRun().GetId(), Attempt: r.GetRun().GetAttempt(),
415+ }))
416+ if connect.CodeOf(err) == connect.CodeUnimplemented {
417+ return out, nil
418+ }
419+ if err != nil {
420+ return runNotes{}, err
421+ }
422+ out.items, out.truncated = res.Msg.GetAnnotations(), res.Msg.GetTruncated()
423+ return out, nil
424+}
425+
426+func annotationLevelText(l ermite.AnnotationLevel) string {
427+ switch l {
428+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_NOTICE:
429+ return "notice"
430+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_WARNING:
431+ return "warning"
432+ case ermite.AnnotationLevel_ANNOTATION_LEVEL_FAILURE:
433+ return "error"
434+ }
435+ return ""
436+}
437+
438+// annotationPlace is "file:line" (or "file:line-endline"), "" without a file.
439+func annotationPlace(a *ermite.Annotation) string {
440+ if a.GetFile() == "" {
441+ return ""
442+ }
443+ p := a.GetFile()
444+ if a.GetStartLine() > 0 {
445+ p += ":" + strconv.Itoa(int(a.GetStartLine()))
446+ if a.GetEndLine() > a.GetStartLine() {
447+ p += "-" + strconv.Itoa(int(a.GetEndLine()))
448+ }
449+ }
450+ return p
451+}
452+
453+func toJSONAnnotation(ja *ermite.JobAnnotation, jobs map[string]*ermite.RunJob) jsonAnnotation {
454+ a := ja.GetAnnotation()
455+ level := annotationLevelText(a.GetLevel())
456+ if level == "error" {
457+ level = "failure" // the API's name, as GitHub's annotation_level
458+ }
459+ return jsonAnnotation{
460+ Job: jobs[ja.GetJobId()].GetName(), JobID: ja.GetJobId(), Step: a.GetStepNumber(), Level: level, Title: a.GetTitle(),
461+ Message: a.GetMessage(), File: a.GetFile(), StartLine: a.GetStartLine(), EndLine: a.GetEndLine(),
462+ StartColumn: a.GetStartColumn(), EndColumn: a.GetEndColumn(),
463+ }
464+}
465+
466+// printAnnotations prints the ANNOTATIONS section of `run view`, like `gh run
467+// view` does: one line per annotation with its level, its job, its place and
468+// its message. Nothing is printed for a run without annotation.
469+func printAnnotations(w io.Writer, n runNotes) error {
470+ if len(n.items) == 0 {
471+ return nil
472+ }
473+ _, _ = fmt.Fprint(w, "\nANNOTATIONS\n")
474+ rows := make([][]string, 0, len(n.items))
475+ for _, ja := range n.items {
476+ a := ja.GetAnnotation()
477+ msg := a.GetMessage()
478+ if a.GetTitle() != "" {
479+ msg = a.GetTitle() + ": " + msg
480+ }
481+ rows = append(rows, []string{annotationLevelText(a.GetLevel()), n.jobs[ja.GetJobId()].GetName(), annotationPlace(a), msg})
482+ }
483+ if err := table(w, []string{"LEVEL", "JOB", "FILE", "MESSAGE"}, rows); err != nil {
484+ return err
485+ }
486+ if n.truncated {
487+ _, _ = fmt.Fprintf(w, "(more annotations exist than the %d shown)\n", len(n.items))
488+ }
489+ return nil
490+}
491+
492+// printRunSummaries prints the job summaries of the chosen jobs as the Markdown
493+// the steps wrote to w; notes go to errw. With several of them, each is preceded
494+// by a line naming its job and step. Control characters are replaced, as everywhere the CLI prints
495+// text it did not write (a tab becomes a space); line breaks are kept.
496+func printRunSummaries(ctx context.Context, w, errw io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string) error {
497+ jobs, err := pickJobs(r.GetJobs(), sel)
498+ if err != nil {
499+ return err
500+ }
501+ type found struct {
502+ job *ermite.RunJob
503+ sum *ermite.StepSummary
504+ }
505+ var all []found
506+ truncated := false
507+ for _, j := range jobs {
508+ if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED {
509+ continue
510+ }
511+ res, err := c.Runs().GetStepSummaries(ctx, connect.NewRequest(&ermite.GetStepSummariesRequest{Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt()}))
512+ if connect.CodeOf(err) == connect.CodeUnimplemented {
513+ return fmt.Errorf("the server does not keep job summaries (it lacks the ci.summaries feature)")
514+ }
515+ if err != nil {
516+ return err
517+ }
518+ for _, s := range res.Msg.GetSummaries() {
519+ all = append(all, found{j, s})
520+ }
521+ truncated = truncated || res.Msg.GetTruncated()
522+ }
523+ if len(all) == 0 {
524+ noticef2(errw, "the run has no job summary")
525+ return nil
526+ }
527+ for i, f := range all {
528+ if len(all) > 1 {
529+ if i > 0 {
530+ _, _ = fmt.Fprintln(w)
531+ }
532+ _, _ = fmt.Fprintf(w, "== %s: %s ==\n", clean(f.job.GetName()), clean(stepName(f.job, f.sum.GetStepNumber())))
533+ }
534+ text := rawText(f.sum.GetMarkdown())
535+ _, _ = fmt.Fprint(w, text)
536+ if !strings.HasSuffix(text, "\n") {
537+ _, _ = fmt.Fprintln(w)
538+ }
539+ }
540+ if truncated {
541+ noticef2(errw, "some jobs have more summaries than the 20 the server returns")
542+ }
543+ return nil
544+}
545+
546+// stepName is the name of a step of a job, or "step N" when it is not known.
547+func stepName(j *ermite.RunJob, n int32) string {
548+ for _, s := range j.GetSteps() {
549+ if s.GetNumber() == n && s.GetName() != "" {
550+ return s.GetName()
551+ }
552+ }
553+ return "step " + strconv.Itoa(int(n))
554+}
555+
556+// rawText replaces the control characters of a text that may come from the
557+// workflow (clean), line by line, so that its line breaks stay.
558+func rawText(s string) string {
559+ lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
560+ for i, l := range lines {
561+ lines[i] = clean(l)
562+ }
563+ return strings.Join(lines, "\n")
564+}
565+
343566 // pickJobs selects the jobs --job names (an id or a name), or all of them.
344567 func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
345568 if sel == "" {
M internal/app/forge.go +2 -1
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/zerowrap"
1717
1818 "bnema.dev/ermite/internal/adapters/cilogs"
19+ "bnema.dev/ermite/internal/adapters/cisummaries"
1920 "bnema.dev/ermite/internal/adapters/ermitegit"
2021 "bnema.dev/ermite/internal/adapters/eventbus"
2122 "bnema.dev/ermite/internal/adapters/gitcli"
@@ -212,7 +213,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
212213 // CI (ADR 0025): the engine reads workflows through the reader, and plans
213214 // them in the background; a push only queues its refs.
214215 ciCfg := usecase.CIConfig{
215- Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
216+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Summaries: cisummaries.New(cisummaries.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
216217 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
217218 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
218219 PollTimeout: cfg.CIPollTimeout, Archive: git,
M internal/domain/ci.go +8 -4
@@ -198,10 +198,14 @@ type CIRunSource struct {
198198
199199 // CIStep is a step of a job with where its lines are in the job log.
200200 type CIStep struct {
201- Number int32
202- Name string
203- State CIStepState
204- Conclusion CIConclusion
201+ Number int32
202+ Name string
203+ State CIStepState
204+ Conclusion CIConclusion
205+ // Outcome is how the step ended before continue-on-error was applied
206+ // ("steps.<id>.outcome"). Empty for a step that was not completed by a
207+ // runner that reports it: read it as Conclusion.
208+ Outcome CIConclusion
205209 StartedAt time.Time
206210 CompletedAt time.Time
207211 LogStart int64
A internal/domain/ci_annotation.go +115 -0
@@ -0,0 +1,115 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
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.
13+type AnnotationLevel string
14+
15+// Annotation levels.
16+const (
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.
23+func (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.
29+const (
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 = 255
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.
48+type 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.
57+type 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.
67+func 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.
104+func 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+}
A internal/domain/ci_annotation_test.go +74 -0
@@ -0,0 +1,74 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func an(level domain.AnnotationLevel, step int32, msg string) domain.CIAnnotation {
16+ return domain.CIAnnotation{Level: level, Step: step, Message: msg}
17+}
18+
19+func TestCheckAnnotationsLimits(t *testing.T) {
20+ t.Parallel()
21+ n, w, f := domain.AnnotationNotice, domain.AnnotationWarning, domain.AnnotationFailure
22+ same := func(level domain.AnnotationLevel, step int32, count int) []domain.CIAnnotation {
23+ var out []domain.CIAnnotation
24+ for range count {
25+ out = append(out, an(level, step, "m"))
26+ }
27+ return out
28+ }
29+ valid := map[string][]domain.CIAnnotation{
30+ "none": nil,
31+ "ten of a level": same(n, 1, 10),
32+ "ten of each level": append(append(same(n, 1, 10), same(w, 1, 10)...), same(f, 1, 10)...),
33+ "ten per level, two steps": append(same(n, 1, 10), same(n, 2, 10)...),
34+ "4096 characters, 8 KiB": {an(n, 1, strings.Repeat("é", 4096))},
35+ "title 255, file 1024": {{Level: n, Step: 1, Message: "m", Title: strings.Repeat("é", 255), File: strings.Repeat("é", 1024)}},
36+ "step 1000": {an(n, 1000, "m")},
37+ "end line equal to start": {{Level: n, Step: 1, Message: "m", StartLine: 3, EndLine: 3}},
38+ "end line without start": {{Level: n, Step: 1, Message: "m", EndLine: 3}},
39+ }
40+ for name, anns := range valid {
41+ assert.NoError(t, domain.CheckAnnotations(anns), name)
42+ }
43+ invalid := map[string][]domain.CIAnnotation{
44+ "eleven of a level": same(w, 4, 11),
45+ "no level": {{Step: 1, Message: "m"}},
46+ "unknown level": {{Level: "debug", Step: 1, Message: "m"}},
47+ "no message": {an(n, 1, "")},
48+ "message 4097": {an(n, 1, strings.Repeat("é", 4097))},
49+ "title 256": {{Level: n, Step: 1, Message: "m", Title: strings.Repeat("t", 256)}},
50+ "file 1025": {{Level: n, Step: 1, Message: "m", File: strings.Repeat("f", 1025)}},
51+ "step 0": {an(n, 0, "m")},
52+ "step 1001": {an(n, 1001, "m")},
53+ "negative line": {{Level: n, Step: 1, Message: "m", StartLine: -1}},
54+ "negative column": {{Level: n, Step: 1, Message: "m", EndColumn: -1}},
55+ "end before start": {{Level: n, Step: 1, Message: "m", StartLine: 4, EndLine: 3}},
56+ "invalid utf-8": {an(n, 1, "a\xffb")},
57+ "one bad among good": append(same(n, 1, 3), an(n, 1, "")),
58+ }
59+ for name, anns := range invalid {
60+ err := domain.CheckAnnotations(anns)
61+ require.Error(t, err, name)
62+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
63+ }
64+}
65+
66+func TestGroupAnnotationsKeepsTheOrderOfAStep(t *testing.T) {
67+ t.Parallel()
68+ by, steps := domain.GroupAnnotations([]domain.CIAnnotation{
69+ an(domain.AnnotationNotice, 10, "ten-a"), an(domain.AnnotationNotice, 2, "two-a"), an(domain.AnnotationFailure, 10, "ten-b"),
70+ })
71+ assert.Equal(t, []int32{2, 10}, steps)
72+ assert.Equal(t, "ten-a", by[10][0].Message)
73+ assert.Equal(t, "ten-b", by[10][1].Message)
74+}
M internal/ports/ci.go +99 -14
@@ -30,13 +30,30 @@ type CIRunDraft struct {
3030 // exist yet), RevokeJobs the jobs whose repository tokens are deleted by it,
3131 // and Resolutions the action resolutions of a run attempt to keep. They are
3232 // written under the lock of the run that owns them.
33+//
34+// Annotations replace, for each (execution of a job, step) they name, all the
35+// annotations stored for it (none deletes them); ClearAnnotations deletes all
36+// the annotations of the executions of jobs they name, steps included. Both
37+// are written with the batch, so a call is applied whole or not at all.
3338 type CIWrite struct {
34- Runs []domain.CIRun
35- Jobs []domain.CIJob
36- Checks []domain.CICheck
37- RepoTokens []domain.CIRepoToken
38- RevokeJobs []CIJobRef
39- Resolutions []domain.CIActionResolution
39+ Runs []domain.CIRun
40+ Jobs []domain.CIJob
41+ Checks []domain.CICheck
42+ RepoTokens []domain.CIRepoToken
43+ RevokeJobs []CIJobRef
44+ Resolutions []domain.CIActionResolution
45+ Annotations []CIStepAnnotations
46+ ClearAnnotations []CILogRef
47+}
48+
49+// CIStepAnnotations are all the annotations of one step of one execution of a
50+// job, in the order the step produced them.
51+type CIStepAnnotations struct {
52+ Repo domain.RepoName
53+ Exec CILogRef
54+ Step int32
55+ // Items are the annotations; none removes those of the step.
56+ Items []domain.CIAnnotation
4057 }
4158
4259 // CIJobRef names one execution of a job.
@@ -84,6 +101,10 @@ type CIStore interface {
84101 RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
85102 // ListOpenRuns returns the runs that have not completed.
86103 ListOpenRuns(ctx context.Context) ([]domain.CIRun, error)
104+ // JobAnnotations returns the annotations of one execution of a job
105+ // (CIJob.Attempt), ordered by step then by the order the step produced
106+ // them. A job without annotation gives an empty list.
107+ JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error)
87108 // GetJob returns a job of a run at an attempt of the run, or
88109 // domain.ErrNotFound.
89110 GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error)
@@ -122,8 +143,8 @@ type CIStore interface {
122143 }
123144
124145 // CIRunCleaner is what repository deletion needs from CI: the runs of the
125-// repository before its records go, and the removal of their logs after.
126-// *usecase.CI implements it.
146+// repository before its records go, and the removal of their logs and
147+// summaries after. *usecase.CI implements it.
127148 type CIRunCleaner interface {
128149 RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
129150 RemoveLogs(ctx context.Context, runIDs []int64) error
@@ -138,7 +159,8 @@ type CIChecksReader interface {
138159 CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)
139160 }
140161
141-// CILogRef names the log of one execution of a job.
162+// CILogRef names one execution of a job: its log, its summaries and its
163+// annotations are kept under it.
142164 type CILogRef struct {
143165 RunID int64
144166 JobID string
@@ -188,6 +210,29 @@ type CILogStore interface {
188210 RunIDs(ctx context.Context) ([]int64, error)
189211 }
190212
213+// CISummaryStore keeps the job summaries (the Markdown a step wrote to
214+// GITHUB_STEP_SUMMARY): one file per step of an execution of a job, never in
215+// the metadata store.
216+type CISummaryStore interface {
217+ // Put stores the summary of a step, replacing the one it has; the file
218+ // appears whole or not at all. An empty markdown removes the summary.
219+ Put(ctx context.Context, ref CILogRef, step int32, markdown string) error
220+ // List returns the summaries of the steps that have one, by step number,
221+ // at most limit; more is true when there are others. With maxBytes above
222+ // 0 the files are sized (stat) before they are read, and a summary that
223+ // would take the total of the Markdown read past maxBytes is returned
224+ // Omitted, unread, as are all the ones after it: a call never reads more
225+ // than maxBytes.
226+ List(ctx context.Context, ref CILogRef, limit int, maxBytes int64) (summaries []domain.CIStepSummary, more bool, err error)
227+ // RemoveJob deletes the summaries of one execution of a job.
228+ RemoveJob(ctx context.Context, ref CILogRef) error
229+ // RemoveRuns deletes the summaries of the runs.
230+ RemoveRuns(ctx context.Context, runIDs []int64) error
231+ // RunIDs lists the runs that have a summary directory, for the orphan
232+ // sweep.
233+ RunIDs(ctx context.Context) ([]int64, error)
234+}
235+
191236 // WorkflowTrigger starts the workflows that match pushed refs. OnPush only
192237 // queues the work and returns: planning happens in the background and its
193238 // errors never reach the push.
@@ -258,10 +303,13 @@ type FetchJobResult struct {
258303
259304 // StepUpdate is the state of one step as a runner reports it.
260305 type StepUpdate struct {
261- Number int32
262- Name string
263- State domain.CIStepState
264- Conclusion domain.CIConclusion
306+ Number int32
307+ Name string
308+ State domain.CIStepState
309+ Conclusion domain.CIConclusion
310+ // Outcome is how the step ended before continue-on-error; none when the
311+ // runner does not report it.
312+ Outcome domain.CIConclusion
265313 StartedAt time.Time
266314 CompletedAt time.Time
267315 }
@@ -273,6 +321,8 @@ type CompleteJobParams struct {
273321 Outputs map[string]string
274322 RunnerShutdown bool
275323 Steps []StepUpdate
324+ // Annotations replace those of each step they name, as in UpdateJob.
325+ Annotations []domain.CIAnnotation
276326 }
277327
278328 // CompleteJobResult is the stored result of a job.
@@ -337,7 +387,10 @@ type JobCancel struct {
337387 type RunnerService interface {
338388 Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error)
339389 FetchJob(ctx context.Context, secret string, params FetchJobParams) (FetchJobResult, error)
340- UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error
390+ // UpdateJob applies the steps and the annotations whole or not at all: for
391+ // each step number present in annotations, the annotations of that step are
392+ // replaced (also for a step already completed, or not in steps).
393+ UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate, annotations []domain.CIAnnotation) error
341394 AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)
342395 Heartbeat(ctx context.Context, secret, jobID string) error
343396 // WatchJob calls ready once the job token is valid and the stream is
@@ -350,6 +403,10 @@ type RunnerService interface {
350403 // an error: its answer carries Error. A local ("./") or Docker
351404 // ("docker://") reference, or more than 100, is domain.ErrInvalid.
352405 ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error)
406+ // UploadSummary stores the summary of a step, replacing the one it has; an
407+ // empty markdown removes it. More than 1 MiB, invalid UTF-8 or a step
408+ // number outside 1 to 1,000 is domain.ErrInvalid.
409+ UploadSummary(ctx context.Context, secret, jobID string, step int32, markdown string) error
353410 }
354411
355412 // JobView is a job as owners see it.
@@ -445,6 +502,25 @@ type RunnerView struct {
445502 Busy bool
446503 }
447504
505+// JobAnnotation is an annotation with the job it belongs to.
506+type JobAnnotation struct {
507+ JobID string
508+ Annotation domain.CIAnnotation
509+}
510+
511+// AnnotationList are the annotations of a run, at most
512+// domain.CIMaxListedAnnotations.
513+type AnnotationList struct {
514+ Annotations []JobAnnotation
515+ Truncated bool
516+}
517+
518+// SummaryList are the summaries of a job, at most domain.CIMaxListedSummaries.
519+type SummaryList struct {
520+ Summaries []domain.CIStepSummary
521+ Truncated bool
522+}
523+
448524 // RunService is the owner side of CI. *usecase.CI implements it.
449525 type RunService interface {
450526 ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error)
@@ -454,6 +530,15 @@ type RunService interface {
454530 CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)
455531 RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)
456532 ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
533+ // ListAnnotations returns the annotations of a run attempt (0 for the
534+ // latest), of one job when jobID is not empty. Each job gives those of the
535+ // attempt in which it executed.
536+ ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error)
537+ // GetStepSummaries returns the summaries of a job at an attempt (0 for the
538+ // latest), by step number. maxBytes above 0 bounds the Markdown read: the
539+ // summaries past it come back Omitted, without their Markdown (the API
540+ // passes 0, no bound).
541+ GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64) (SummaryList, error)
457542 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
458543 RemoveRunner(ctx context.Context, p domain.Principal, id string) error
459544 }
M internal/ports/mocks/CIStore.go +80 -0
@@ -765,6 +765,86 @@ func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id
765765 return _c
766766 }
767767
768+// JobAnnotations provides a mock function for the type CIStore
769+func (_mock *CIStore) JobAnnotations(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error) {
770+ ret := _mock.Called(ctx, runID, attempt, jobID)
771+
772+ if len(ret) == 0 {
773+ panic("no return value specified for JobAnnotations")
774+ }
775+
776+ var r0 []domain.CIAnnotation
777+ var r1 error
778+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) ([]domain.CIAnnotation, error)); ok {
779+ return returnFunc(ctx, runID, attempt, jobID)
780+ }
781+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) []domain.CIAnnotation); ok {
782+ r0 = returnFunc(ctx, runID, attempt, jobID)
783+ } else {
784+ if ret.Get(0) != nil {
785+ r0 = ret.Get(0).([]domain.CIAnnotation)
786+ }
787+ }
788+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok {
789+ r1 = returnFunc(ctx, runID, attempt, jobID)
790+ } else {
791+ r1 = ret.Error(1)
792+ }
793+ return r0, r1
794+}
795+
796+// CIStore_JobAnnotations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobAnnotations'
797+type CIStore_JobAnnotations_Call struct {
798+ *mock.Call
799+}
800+
801+// JobAnnotations is a helper method to define mock.On call
802+// - ctx context.Context
803+// - runID int64
804+// - attempt int32
805+// - jobID string
806+func (_e *CIStore_Expecter) JobAnnotations(ctx any, runID any, attempt any, jobID any) *CIStore_JobAnnotations_Call {
807+ return &CIStore_JobAnnotations_Call{Call: _e.mock.On("JobAnnotations", ctx, runID, attempt, jobID)}
808+}
809+
810+func (_c *CIStore_JobAnnotations_Call) Run(run func(ctx context.Context, runID int64, attempt int32, jobID string)) *CIStore_JobAnnotations_Call {
811+ _c.Call.Run(func(args mock.Arguments) {
812+ var arg0 context.Context
813+ if args[0] != nil {
814+ arg0 = args[0].(context.Context)
815+ }
816+ var arg1 int64
817+ if args[1] != nil {
818+ arg1 = args[1].(int64)
819+ }
820+ var arg2 int32
821+ if args[2] != nil {
822+ arg2 = args[2].(int32)
823+ }
824+ var arg3 string
825+ if args[3] != nil {
826+ arg3 = args[3].(string)
827+ }
828+ run(
829+ arg0,
830+ arg1,
831+ arg2,
832+ arg3,
833+ )
834+ })
835+ return _c
836+}
837+
838+func (_c *CIStore_JobAnnotations_Call) Return(cIAnnotations []domain.CIAnnotation, err error) *CIStore_JobAnnotations_Call {
839+ _c.Call.Return(cIAnnotations, err)
840+ return _c
841+}
842+
843+func (_c *CIStore_JobAnnotations_Call) RunAndReturn(run func(ctx context.Context, runID int64, attempt int32, jobID string) ([]domain.CIAnnotation, error)) *CIStore_JobAnnotations_Call {
844+ _c.Call.Return(run)
845+ return _c
846+}
847+
768848 // ListActiveJobs provides a mock function for the type CIStore
769849 func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
770850 ret := _mock.Called(ctx)
A internal/ports/mocks/CISummaryStore.go +380 -0
@@ -0,0 +1,380 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewCISummaryStore creates a new instance of CISummaryStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewCISummaryStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *CISummaryStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &CISummaryStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// CISummaryStore is an autogenerated mock type for the CISummaryStore type
39+type CISummaryStore struct {
40+ mock.Mock
41+}
42+
43+type CISummaryStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *CISummaryStore) EXPECT() *CISummaryStore_Expecter {
48+ return &CISummaryStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// List provides a mock function for the type CISummaryStore
52+func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
53+ ret := _mock.Called(ctx, ref, limit, maxBytes)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for List")
57+ }
58+
59+ var r0 []domain.CIStepSummary
60+ var r1 bool
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int, int64) ([]domain.CIStepSummary, bool, error)); ok {
63+ return returnFunc(ctx, ref, limit, maxBytes)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int, int64) []domain.CIStepSummary); ok {
66+ r0 = returnFunc(ctx, ref, limit, maxBytes)
67+ } else {
68+ if ret.Get(0) != nil {
69+ r0 = ret.Get(0).([]domain.CIStepSummary)
70+ }
71+ }
72+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int, int64) bool); ok {
73+ r1 = returnFunc(ctx, ref, limit, maxBytes)
74+ } else {
75+ r1 = ret.Get(1).(bool)
76+ }
77+ if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int, int64) error); ok {
78+ r2 = returnFunc(ctx, ref, limit, maxBytes)
79+ } else {
80+ r2 = ret.Error(2)
81+ }
82+ return r0, r1, r2
83+}
84+
85+// CISummaryStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
86+type CISummaryStore_List_Call struct {
87+ *mock.Call
88+}
89+
90+// List is a helper method to define mock.On call
91+// - ctx context.Context
92+// - ref ports.CILogRef
93+// - limit int
94+// - maxBytes int64
95+func (_e *CISummaryStore_Expecter) List(ctx any, ref any, limit any, maxBytes any) *CISummaryStore_List_Call {
96+ return &CISummaryStore_List_Call{Call: _e.mock.On("List", ctx, ref, limit, maxBytes)}
97+}
98+
99+func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64)) *CISummaryStore_List_Call {
100+ _c.Call.Run(func(args mock.Arguments) {
101+ var arg0 context.Context
102+ if args[0] != nil {
103+ arg0 = args[0].(context.Context)
104+ }
105+ var arg1 ports.CILogRef
106+ if args[1] != nil {
107+ arg1 = args[1].(ports.CILogRef)
108+ }
109+ var arg2 int
110+ if args[2] != nil {
111+ arg2 = args[2].(int)
112+ }
113+ var arg3 int64
114+ if args[3] != nil {
115+ arg3 = args[3].(int64)
116+ }
117+ run(
118+ arg0,
119+ arg1,
120+ arg2,
121+ arg3,
122+ )
123+ })
124+ return _c
125+}
126+
127+func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, more bool, err error) *CISummaryStore_List_Call {
128+ _c.Call.Return(summaries, more, err)
129+ return _c
130+}
131+
132+func (_c *CISummaryStore_List_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error)) *CISummaryStore_List_Call {
133+ _c.Call.Return(run)
134+ return _c
135+}
136+
137+// Put provides a mock function for the type CISummaryStore
138+func (_mock *CISummaryStore) Put(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error {
139+ ret := _mock.Called(ctx, ref, step, markdown)
140+
141+ if len(ret) == 0 {
142+ panic("no return value specified for Put")
143+ }
144+
145+ var r0 error
146+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int32, string) error); ok {
147+ r0 = returnFunc(ctx, ref, step, markdown)
148+ } else {
149+ r0 = ret.Error(0)
150+ }
151+ return r0
152+}
153+
154+// CISummaryStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put'
155+type CISummaryStore_Put_Call struct {
156+ *mock.Call
157+}
158+
159+// Put is a helper method to define mock.On call
160+// - ctx context.Context
161+// - ref ports.CILogRef
162+// - step int32
163+// - markdown string
164+func (_e *CISummaryStore_Expecter) Put(ctx any, ref any, step any, markdown any) *CISummaryStore_Put_Call {
165+ return &CISummaryStore_Put_Call{Call: _e.mock.On("Put", ctx, ref, step, markdown)}
166+}
167+
168+func (_c *CISummaryStore_Put_Call) Run(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string)) *CISummaryStore_Put_Call {
169+ _c.Call.Run(func(args mock.Arguments) {
170+ var arg0 context.Context
171+ if args[0] != nil {
172+ arg0 = args[0].(context.Context)
173+ }
174+ var arg1 ports.CILogRef
175+ if args[1] != nil {
176+ arg1 = args[1].(ports.CILogRef)
177+ }
178+ var arg2 int32
179+ if args[2] != nil {
180+ arg2 = args[2].(int32)
181+ }
182+ var arg3 string
183+ if args[3] != nil {
184+ arg3 = args[3].(string)
185+ }
186+ run(
187+ arg0,
188+ arg1,
189+ arg2,
190+ arg3,
191+ )
192+ })
193+ return _c
194+}
195+
196+func (_c *CISummaryStore_Put_Call) Return(err error) *CISummaryStore_Put_Call {
197+ _c.Call.Return(err)
198+ return _c
199+}
200+
201+func (_c *CISummaryStore_Put_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, step int32, markdown string) error) *CISummaryStore_Put_Call {
202+ _c.Call.Return(run)
203+ return _c
204+}
205+
206+// RemoveJob provides a mock function for the type CISummaryStore
207+func (_mock *CISummaryStore) RemoveJob(ctx context.Context, ref ports.CILogRef) error {
208+ ret := _mock.Called(ctx, ref)
209+
210+ if len(ret) == 0 {
211+ panic("no return value specified for RemoveJob")
212+ }
213+
214+ var r0 error
215+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) error); ok {
216+ r0 = returnFunc(ctx, ref)
217+ } else {
218+ r0 = ret.Error(0)
219+ }
220+ return r0
221+}
222+
223+// CISummaryStore_RemoveJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveJob'
224+type CISummaryStore_RemoveJob_Call struct {
225+ *mock.Call
226+}
227+
228+// RemoveJob is a helper method to define mock.On call
229+// - ctx context.Context
230+// - ref ports.CILogRef
231+func (_e *CISummaryStore_Expecter) RemoveJob(ctx any, ref any) *CISummaryStore_RemoveJob_Call {
232+ return &CISummaryStore_RemoveJob_Call{Call: _e.mock.On("RemoveJob", ctx, ref)}
233+}
234+
235+func (_c *CISummaryStore_RemoveJob_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CISummaryStore_RemoveJob_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 context.Context
238+ if args[0] != nil {
239+ arg0 = args[0].(context.Context)
240+ }
241+ var arg1 ports.CILogRef
242+ if args[1] != nil {
243+ arg1 = args[1].(ports.CILogRef)
244+ }
245+ run(
246+ arg0,
247+ arg1,
248+ )
249+ })
250+ return _c
251+}
252+
253+func (_c *CISummaryStore_RemoveJob_Call) Return(err error) *CISummaryStore_RemoveJob_Call {
254+ _c.Call.Return(err)
255+ return _c
256+}
257+
258+func (_c *CISummaryStore_RemoveJob_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) error) *CISummaryStore_RemoveJob_Call {
259+ _c.Call.Return(run)
260+ return _c
261+}
262+
263+// RemoveRuns provides a mock function for the type CISummaryStore
264+func (_mock *CISummaryStore) RemoveRuns(ctx context.Context, runIDs []int64) error {
265+ ret := _mock.Called(ctx, runIDs)
266+
267+ if len(ret) == 0 {
268+ panic("no return value specified for RemoveRuns")
269+ }
270+
271+ var r0 error
272+ if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
273+ r0 = returnFunc(ctx, runIDs)
274+ } else {
275+ r0 = ret.Error(0)
276+ }
277+ return r0
278+}
279+
280+// CISummaryStore_RemoveRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRuns'
281+type CISummaryStore_RemoveRuns_Call struct {
282+ *mock.Call
283+}
284+
285+// RemoveRuns is a helper method to define mock.On call
286+// - ctx context.Context
287+// - runIDs []int64
288+func (_e *CISummaryStore_Expecter) RemoveRuns(ctx any, runIDs any) *CISummaryStore_RemoveRuns_Call {
289+ return &CISummaryStore_RemoveRuns_Call{Call: _e.mock.On("RemoveRuns", ctx, runIDs)}
290+}
291+
292+func (_c *CISummaryStore_RemoveRuns_Call) Run(run func(ctx context.Context, runIDs []int64)) *CISummaryStore_RemoveRuns_Call {
293+ _c.Call.Run(func(args mock.Arguments) {
294+ var arg0 context.Context
295+ if args[0] != nil {
296+ arg0 = args[0].(context.Context)
297+ }
298+ var arg1 []int64
299+ if args[1] != nil {
300+ arg1 = args[1].([]int64)
301+ }
302+ run(
303+ arg0,
304+ arg1,
305+ )
306+ })
307+ return _c
308+}
309+
310+func (_c *CISummaryStore_RemoveRuns_Call) Return(err error) *CISummaryStore_RemoveRuns_Call {
311+ _c.Call.Return(err)
312+ return _c
313+}
314+
315+func (_c *CISummaryStore_RemoveRuns_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CISummaryStore_RemoveRuns_Call {
316+ _c.Call.Return(run)
317+ return _c
318+}
319+
320+// RunIDs provides a mock function for the type CISummaryStore
321+func (_mock *CISummaryStore) RunIDs(ctx context.Context) ([]int64, error) {
322+ ret := _mock.Called(ctx)
323+
324+ if len(ret) == 0 {
325+ panic("no return value specified for RunIDs")
326+ }
327+
328+ var r0 []int64
329+ var r1 error
330+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]int64, error)); ok {
331+ return returnFunc(ctx)
332+ }
333+ if returnFunc, ok := ret.Get(0).(func(context.Context) []int64); ok {
334+ r0 = returnFunc(ctx)
335+ } else {
336+ if ret.Get(0) != nil {
337+ r0 = ret.Get(0).([]int64)
338+ }
339+ }
340+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
341+ r1 = returnFunc(ctx)
342+ } else {
343+ r1 = ret.Error(1)
344+ }
345+ return r0, r1
346+}
347+
348+// CISummaryStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs'
349+type CISummaryStore_RunIDs_Call struct {
350+ *mock.Call
351+}
352+
353+// RunIDs is a helper method to define mock.On call
354+// - ctx context.Context
355+func (_e *CISummaryStore_Expecter) RunIDs(ctx any) *CISummaryStore_RunIDs_Call {
356+ return &CISummaryStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx)}
357+}
358+
359+func (_c *CISummaryStore_RunIDs_Call) Run(run func(ctx context.Context)) *CISummaryStore_RunIDs_Call {
360+ _c.Call.Run(func(args mock.Arguments) {
361+ var arg0 context.Context
362+ if args[0] != nil {
363+ arg0 = args[0].(context.Context)
364+ }
365+ run(
366+ arg0,
367+ )
368+ })
369+ return _c
370+}
371+
372+func (_c *CISummaryStore_RunIDs_Call) Return(int64s []int64, err error) *CISummaryStore_RunIDs_Call {
373+ _c.Call.Return(int64s, err)
374+ return _c
375+}
376+
377+func (_c *CISummaryStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context) ([]int64, error)) *CISummaryStore_RunIDs_Call {
378+ _c.Call.Return(run)
379+ return _c
380+}
M internal/ports/mocks/RunService.go +180 -0
@@ -369,6 +369,186 @@ func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p d
369369 return _c
370370 }
371371
372+// GetStepSummaries provides a mock function for the type RunService
373+func (_mock *RunService) GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64) (ports.SummaryList, error) {
374+ ret := _mock.Called(ctx, p, repo, jobID, attempt, maxBytes)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for GetStepSummaries")
378+ }
379+
380+ var r0 ports.SummaryList
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32, int64) (ports.SummaryList, error)); ok {
383+ return returnFunc(ctx, p, repo, jobID, attempt, maxBytes)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32, int64) ports.SummaryList); ok {
386+ r0 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes)
387+ } else {
388+ r0 = ret.Get(0).(ports.SummaryList)
389+ }
390+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32, int64) error); ok {
391+ r1 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes)
392+ } else {
393+ r1 = ret.Error(1)
394+ }
395+ return r0, r1
396+}
397+
398+// RunService_GetStepSummaries_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStepSummaries'
399+type RunService_GetStepSummaries_Call struct {
400+ *mock.Call
401+}
402+
403+// GetStepSummaries is a helper method to define mock.On call
404+// - ctx context.Context
405+// - p domain.Principal
406+// - repo domain.RepoName
407+// - jobID string
408+// - attempt int32
409+// - maxBytes int64
410+func (_e *RunService_Expecter) GetStepSummaries(ctx any, p any, repo any, jobID any, attempt any, maxBytes any) *RunService_GetStepSummaries_Call {
411+ return &RunService_GetStepSummaries_Call{Call: _e.mock.On("GetStepSummaries", ctx, p, repo, jobID, attempt, maxBytes)}
412+}
413+
414+func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64)) *RunService_GetStepSummaries_Call {
415+ _c.Call.Run(func(args mock.Arguments) {
416+ var arg0 context.Context
417+ if args[0] != nil {
418+ arg0 = args[0].(context.Context)
419+ }
420+ var arg1 domain.Principal
421+ if args[1] != nil {
422+ arg1 = args[1].(domain.Principal)
423+ }
424+ var arg2 domain.RepoName
425+ if args[2] != nil {
426+ arg2 = args[2].(domain.RepoName)
427+ }
428+ var arg3 string
429+ if args[3] != nil {
430+ arg3 = args[3].(string)
431+ }
432+ var arg4 int32
433+ if args[4] != nil {
434+ arg4 = args[4].(int32)
435+ }
436+ var arg5 int64
437+ if args[5] != nil {
438+ arg5 = args[5].(int64)
439+ }
440+ run(
441+ arg0,
442+ arg1,
443+ arg2,
444+ arg3,
445+ arg4,
446+ arg5,
447+ )
448+ })
449+ return _c
450+}
451+
452+func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList, err error) *RunService_GetStepSummaries_Call {
453+ _c.Call.Return(summaryList, err)
454+ return _c
455+}
456+
457+func (_c *RunService_GetStepSummaries_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32, maxBytes int64) (ports.SummaryList, error)) *RunService_GetStepSummaries_Call {
458+ _c.Call.Return(run)
459+ return _c
460+}
461+
462+// ListAnnotations provides a mock function for the type RunService
463+func (_mock *RunService) ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error) {
464+ ret := _mock.Called(ctx, p, repo, runID, attempt, jobID)
465+
466+ if len(ret) == 0 {
467+ panic("no return value specified for ListAnnotations")
468+ }
469+
470+ var r0 ports.AnnotationList
471+ var r1 error
472+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) (ports.AnnotationList, error)); ok {
473+ return returnFunc(ctx, p, repo, runID, attempt, jobID)
474+ }
475+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) ports.AnnotationList); ok {
476+ r0 = returnFunc(ctx, p, repo, runID, attempt, jobID)
477+ } else {
478+ r0 = ret.Get(0).(ports.AnnotationList)
479+ }
480+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int32, string) error); ok {
481+ r1 = returnFunc(ctx, p, repo, runID, attempt, jobID)
482+ } else {
483+ r1 = ret.Error(1)
484+ }
485+ return r0, r1
486+}
487+
488+// RunService_ListAnnotations_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAnnotations'
489+type RunService_ListAnnotations_Call struct {
490+ *mock.Call
491+}
492+
493+// ListAnnotations is a helper method to define mock.On call
494+// - ctx context.Context
495+// - p domain.Principal
496+// - repo domain.RepoName
497+// - runID int64
498+// - attempt int32
499+// - jobID string
500+func (_e *RunService_Expecter) ListAnnotations(ctx any, p any, repo any, runID any, attempt any, jobID any) *RunService_ListAnnotations_Call {
501+ return &RunService_ListAnnotations_Call{Call: _e.mock.On("ListAnnotations", ctx, p, repo, runID, attempt, jobID)}
502+}
503+
504+func (_c *RunService_ListAnnotations_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string)) *RunService_ListAnnotations_Call {
505+ _c.Call.Run(func(args mock.Arguments) {
506+ var arg0 context.Context
507+ if args[0] != nil {
508+ arg0 = args[0].(context.Context)
509+ }
510+ var arg1 domain.Principal
511+ if args[1] != nil {
512+ arg1 = args[1].(domain.Principal)
513+ }
514+ var arg2 domain.RepoName
515+ if args[2] != nil {
516+ arg2 = args[2].(domain.RepoName)
517+ }
518+ var arg3 int64
519+ if args[3] != nil {
520+ arg3 = args[3].(int64)
521+ }
522+ var arg4 int32
523+ if args[4] != nil {
524+ arg4 = args[4].(int32)
525+ }
526+ var arg5 string
527+ if args[5] != nil {
528+ arg5 = args[5].(string)
529+ }
530+ run(
531+ arg0,
532+ arg1,
533+ arg2,
534+ arg3,
535+ arg4,
536+ arg5,
537+ )
538+ })
539+ return _c
540+}
541+
542+func (_c *RunService_ListAnnotations_Call) Return(annotationList ports.AnnotationList, err error) *RunService_ListAnnotations_Call {
543+ _c.Call.Return(annotationList, err)
544+ return _c
545+}
546+
547+func (_c *RunService_ListAnnotations_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error)) *RunService_ListAnnotations_Call {
548+ _c.Call.Return(run)
549+ return _c
550+}
551+
372552 // ListChecks provides a mock function for the type RunService
373553 func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) {
374554 ret := _mock.Called(ctx, p, repo, sha)
M internal/ports/mocks/RunnerService.go +89 -8
@@ -498,16 +498,16 @@ func (_c *RunnerService_ResolveActions_Call) RunAndReturn(run func(ctx context.C
498498 }
499499
500500 // UpdateJob provides a mock function for the type RunnerService
501-func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
502- ret := _mock.Called(ctx, secret, jobID, steps)
501+func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error {
502+ ret := _mock.Called(ctx, secret, jobID, steps, annotations)
503503
504504 if len(ret) == 0 {
505505 panic("no return value specified for UpdateJob")
506506 }
507507
508508 var r0 error
509- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate) error); ok {
510- r0 = returnFunc(ctx, secret, jobID, steps)
509+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate, []domain.CIAnnotation) error); ok {
510+ r0 = returnFunc(ctx, secret, jobID, steps, annotations)
511511 } else {
512512 r0 = ret.Error(0)
513513 }
@@ -524,11 +524,12 @@ type RunnerService_UpdateJob_Call struct {
524524 // - secret string
525525 // - jobID string
526526 // - steps []ports.StepUpdate
527-func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any) *RunnerService_UpdateJob_Call {
528- return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps)}
527+// - annotations []domain.CIAnnotation
528+func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any, annotations any) *RunnerService_UpdateJob_Call {
529+ return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps, annotations)}
529530 }
530531
531-func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate)) *RunnerService_UpdateJob_Call {
532+func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation)) *RunnerService_UpdateJob_Call {
532533 _c.Call.Run(func(args mock.Arguments) {
533534 var arg0 context.Context
534535 if args[0] != nil {
@@ -546,11 +547,16 @@ func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret
546547 if args[3] != nil {
547548 arg3 = args[3].([]ports.StepUpdate)
548549 }
550+ var arg4 []domain.CIAnnotation
551+ if args[4] != nil {
552+ arg4 = args[4].([]domain.CIAnnotation)
553+ }
549554 run(
550555 arg0,
551556 arg1,
552557 arg2,
553558 arg3,
559+ arg4,
554560 )
555561 })
556562 return _c
@@ -561,7 +567,82 @@ func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJ
561567 return _c
562568 }
563569
564-func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error) *RunnerService_UpdateJob_Call {
570+func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error) *RunnerService_UpdateJob_Call {
571+ _c.Call.Return(run)
572+ return _c
573+}
574+
575+// UploadSummary provides a mock function for the type RunnerService
576+func (_mock *RunnerService) UploadSummary(ctx context.Context, secret string, jobID string, step int32, markdown string) error {
577+ ret := _mock.Called(ctx, secret, jobID, step, markdown)
578+
579+ if len(ret) == 0 {
580+ panic("no return value specified for UploadSummary")
581+ }
582+
583+ var r0 error
584+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, string) error); ok {
585+ r0 = returnFunc(ctx, secret, jobID, step, markdown)
586+ } else {
587+ r0 = ret.Error(0)
588+ }
589+ return r0
590+}
591+
592+// RunnerService_UploadSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadSummary'
593+type RunnerService_UploadSummary_Call struct {
594+ *mock.Call
595+}
596+
597+// UploadSummary is a helper method to define mock.On call
598+// - ctx context.Context
599+// - secret string
600+// - jobID string
601+// - step int32
602+// - markdown string
603+func (_e *RunnerService_Expecter) UploadSummary(ctx any, secret any, jobID any, step any, markdown any) *RunnerService_UploadSummary_Call {
604+ return &RunnerService_UploadSummary_Call{Call: _e.mock.On("UploadSummary", ctx, secret, jobID, step, markdown)}
605+}
606+
607+func (_c *RunnerService_UploadSummary_Call) Run(run func(ctx context.Context, secret string, jobID string, step int32, markdown string)) *RunnerService_UploadSummary_Call {
608+ _c.Call.Run(func(args mock.Arguments) {
609+ var arg0 context.Context
610+ if args[0] != nil {
611+ arg0 = args[0].(context.Context)
612+ }
613+ var arg1 string
614+ if args[1] != nil {
615+ arg1 = args[1].(string)
616+ }
617+ var arg2 string
618+ if args[2] != nil {
619+ arg2 = args[2].(string)
620+ }
621+ var arg3 int32
622+ if args[3] != nil {
623+ arg3 = args[3].(int32)
624+ }
625+ var arg4 string
626+ if args[4] != nil {
627+ arg4 = args[4].(string)
628+ }
629+ run(
630+ arg0,
631+ arg1,
632+ arg2,
633+ arg3,
634+ arg4,
635+ )
636+ })
637+ return _c
638+}
639+
640+func (_c *RunnerService_UploadSummary_Call) Return(err error) *RunnerService_UploadSummary_Call {
641+ _c.Call.Return(err)
642+ return _c
643+}
644+
645+func (_c *RunnerService_UploadSummary_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, step int32, markdown string) error) *RunnerService_UploadSummary_Call {
565646 _c.Call.Return(run)
566647 return _c
567648 }
M internal/usecase/ci.go +20 -11
@@ -46,19 +46,21 @@ const (
4646 maxCIWorkflowFiles = 256
4747 )
4848
49-// CIConfig holds what the CI use cases need. Store, Logs, Meta, Repos, Reader,
49+// CIConfig holds what the CI use cases need. Store, Logs, Summaries, Meta, Repos, Reader,
5050 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
5151 // the defaults of Owner and Retention).
5252 type CIConfig struct {
53- Store ports.CIStore
54- Logs ports.CILogStore
55- Meta ports.RepoMetaStore
56- Repos ports.GitRepos
57- Reader ports.RepoReader
58- Git ports.MergeGit
59- Diff ports.CIGit
60- Bus ports.EventBus
61- Clock ports.Clock
53+ Store ports.CIStore
54+ Logs ports.CILogStore
55+ // Summaries keeps the job summaries, deleted with the logs of their run.
56+ Summaries ports.CISummaryStore
57+ Meta ports.RepoMetaStore
58+ Repos ports.GitRepos
59+ Reader ports.RepoReader
60+ Git ports.MergeGit
61+ Diff ports.CIGit
62+ Bus ports.EventBus
63+ Clock ports.Clock
6264 // Auth re-validates the token of a log stream; nil skips it.
6365 Auth ports.Revalidator
6466 // Remote lists the refs of github.com repositories for ResolveActions; nil
@@ -295,6 +297,10 @@ type runState struct {
295297 revoke []ports.CIJobRef
296298 // resolutions are the action resolutions to keep with the next flush.
297299 resolutions []domain.CIActionResolution
300+ // annotations replace the annotations of steps, clear drops all those of
301+ // executions of jobs, with the next flush.
302+ annotations []ports.CIStepAnnotations
303+ clear []ports.CILogRef
298304 }
299305
300306 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)
@@ -379,7 +385,9 @@ func (rs *runState) flush(ctx context.Context) error {
379385 w.Runs = []domain.CIRun{rs.run}
380386 }
381387 w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions
382- if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 && len(w.Resolutions) == 0 {
388+ w.Annotations, w.ClearAnnotations = rs.annotations, rs.clear
389+ if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 &&
390+ len(w.Resolutions) == 0 && len(w.Annotations) == 0 && len(w.ClearAnnotations) == 0 {
383391 return nil
384392 }
385393 v, err := c.cfg.Store.Apply(ctx, w)
@@ -395,6 +403,7 @@ func (rs *runState) flush(ctx context.Context) error {
395403 changedRun := rs.runDirty
396404 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
397405 rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
406+ rs.annotations, rs.clear = nil, nil
398407
399408 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
400409 if err != nil {
M internal/usecase/ci_plan.go +1 -0
@@ -231,6 +231,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
231231 if s.State == domain.CIStepPending && !cancelish {
232232 s.Conclusion = domain.CISkipped
233233 }
234+ s.Outcome = s.Conclusion
234235 s.State, s.CompletedAt = domain.CIStepCompleted, now
235236 }
236237 j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now
A internal/usecase/ci_results.go +129 -0
@@ -0,0 +1,129 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "fmt"
8+ "slices"
9+ "unicode/utf8"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// The results of a job beyond its steps and its log: annotations and step
16+// summaries (runner.proto, run.proto).
17+
18+// annotationWrites returns the replacements an UpdateJob or CompleteJob makes:
19+// one per step the request names whose stored annotations differ, so that
20+// repeating a call changes nothing. The request was validated.
21+func (c *CI) annotationWrites(ctx context.Context, j domain.CIJob, anns []domain.CIAnnotation) ([]ports.CIStepAnnotations, error) {
22+ if len(anns) == 0 {
23+ return nil, nil
24+ }
25+ stored, err := c.cfg.Store.JobAnnotations(ctx, j.RunID, j.Attempt, j.ID)
26+ if err != nil {
27+ return nil, fmt.Errorf("read the annotations of job %s: %w", j.ID, err)
28+ }
29+ have, _ := domain.GroupAnnotations(stored)
30+ want, steps := domain.GroupAnnotations(anns)
31+ var out []ports.CIStepAnnotations
32+ for _, step := range steps {
33+ if slices.Equal(have[step], want[step]) {
34+ continue
35+ }
36+ out = append(out, ports.CIStepAnnotations{Repo: j.Repo, Exec: logRef(j), Step: step, Items: want[step]})
37+ }
38+ return out, nil
39+}
40+
41+// UploadSummary stores the summary of a step, replacing the one it has. An
42+// empty Markdown removes it.
43+func (c *CI) UploadSummary(ctx context.Context, secret, jobID string, step int32, markdown string) error {
44+ j, err := c.jobFor(ctx, secret, jobID)
45+ if err != nil {
46+ return err
47+ }
48+ if j.Ended() {
49+ return endedError(j)
50+ }
51+ switch {
52+ case step < 1 || step > domain.CIMaxSteps:
53+ return fmt.Errorf("%w: step %d is not between 1 and %d", domain.ErrInvalid, step, domain.CIMaxSteps)
54+ case len(markdown) > domain.CIMaxSummaryBytes:
55+ return fmt.Errorf("%w: the summary is larger than 1 MiB", domain.ErrInvalid)
56+ case !utf8.ValidString(markdown):
57+ return fmt.Errorf("%w: the summary is not valid UTF-8", domain.ErrInvalid)
58+ }
59+ // Under the lock of the run, so that the end of the job and the summary do
60+ // not cross: after the end nothing is stored.
61+ return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
62+ if cur.Ended() {
63+ return endedError(*cur)
64+ }
65+ return c.cfg.Summaries.Put(ctx, logRef(*cur), step, markdown)
66+ })
67+}
68+
69+// ListAnnotations returns the annotations of a run attempt (0 for the latest),
70+// of one job when jobID is set, ordered by job, step and production order.
71+// Each job gives those of the attempt in which it executed.
72+func (c *CI) ListAnnotations(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, attempt int32, jobID string) (ports.AnnotationList, error) {
73+ if _, err := c.readableRepo(ctx, p, name); err != nil {
74+ return ports.AnnotationList{}, err
75+ }
76+ run, err := c.runOf(ctx, name, runID)
77+ if err != nil {
78+ return ports.AnnotationList{}, err
79+ }
80+ if attempt < 0 || attempt > run.Attempt {
81+ return ports.AnnotationList{}, domain.ErrNotFound
82+ }
83+ if attempt == 0 {
84+ attempt = run.Attempt
85+ }
86+ jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt)
87+ if err != nil {
88+ return ports.AnnotationList{}, err
89+ }
90+ if jobID != "" {
91+ jobs = slices.DeleteFunc(jobs, func(j domain.CIJob) bool { return j.ID != jobID })
92+ if len(jobs) == 0 {
93+ return ports.AnnotationList{}, domain.ErrNotFound
94+ }
95+ }
96+ out := ports.AnnotationList{Annotations: []ports.JobAnnotation{}}
97+ for _, j := range jobs {
98+ anns, err := c.cfg.Store.JobAnnotations(ctx, j.RunID, j.Attempt, j.ID)
99+ if err != nil {
100+ return ports.AnnotationList{}, err
101+ }
102+ for _, a := range anns {
103+ if len(out.Annotations) >= domain.CIMaxListedAnnotations {
104+ out.Truncated = true
105+ return out, nil
106+ }
107+ out.Annotations = append(out.Annotations, ports.JobAnnotation{JobID: j.ID, Annotation: a})
108+ }
109+ }
110+ return out, nil
111+}
112+
113+// GetStepSummaries returns the summaries of a job at an attempt (0 for the
114+// latest): the first 20 steps that have one, by step number. With maxBytes
115+// above 0, no more than that many bytes of Markdown are read.
116+func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32, maxBytes int64) (ports.SummaryList, error) {
117+ if _, err := c.readableRepo(ctx, p, name); err != nil {
118+ return ports.SummaryList{}, err
119+ }
120+ _, j, err := c.jobOf(ctx, name, jobID, attempt)
121+ if err != nil {
122+ return ports.SummaryList{}, err
123+ }
124+ list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries, max(maxBytes, 0))
125+ if err != nil {
126+ return ports.SummaryList{}, err
127+ }
128+ return ports.SummaryList{Summaries: list, Truncated: more}, nil
129+}
A internal/usecase/ci_results_unit_test.go +252 -0
@@ -0,0 +1,252 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+const otherJobSecret = "ermitejob_ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkj"
19+
20+func warn(step int32, msg string) domain.CIAnnotation {
21+ return domain.CIAnnotation{Level: domain.AnnotationWarning, Step: step, Message: msg}
22+}
23+
24+// UpdateJob replaces the annotations of the steps it names, in the batch of the
25+// job, also for a completed step and a step that is not in the call; a retry
26+// writes nothing.
27+func TestUpdateJobReplacesAnnotationsPerStep(t *testing.T) {
28+ t.Parallel()
29+ u := newCIUnit(t, nil)
30+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
31+ j.Steps = []domain.CIStep{{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}}
32+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(jobSecret), mock.Anything).Return(j, nil)
33+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
34+ u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(1, "old"), warn(5, "kept")}, nil).Once()
35+ var written ports.CIWrite
36+ u.store.EXPECT().Apply(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, w ports.CIWrite) (uint64, error) {
37+ written = w
38+ return 9, nil
39+ }).Once()
40+ u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo}, nil)
41+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
42+ u.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
43+
44+ // Step 1 is completed: its annotations are replaced, the step is not touched.
45+ completed := ports.StepUpdate{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}
46+ require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{completed},
47+ []domain.CIAnnotation{warn(1, "new"), warn(1, "new too"), warn(9, "a step not in the call")}))
48+
49+ require.Len(t, written.Annotations, 2, "step 5 is not named: it is kept")
50+ assert.EqualValues(t, 1, written.Annotations[0].Step)
51+ assert.Equal(t, []string{"new", "new too"}, []string{written.Annotations[0].Items[0].Message, written.Annotations[0].Items[1].Message})
52+ assert.EqualValues(t, 9, written.Annotations[1].Step)
53+ assert.Equal(t, ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}, written.Annotations[0].Exec, "keyed by the attempt the job executes in")
54+ assert.Equal(t, j.Repo, written.Annotations[0].Repo)
55+ require.Len(t, written.Jobs, 1, "annotations travel with the job record")
56+}
57+
58+func TestUpdateJobRetryWritesNothing(t *testing.T) {
59+ t.Parallel()
60+ u := newCIUnit(t, nil)
61+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
62+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
63+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
64+ u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(2, "same")}, nil)
65+ // No Apply is expected: the mock fails the test on one.
66+ require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(2, "same")}))
67+}
68+
69+// A call that breaks a limit is refused before anything is read or written.
70+func TestUpdateJobAnnotationLimitsStopBeforeTheStore(t *testing.T) {
71+ t.Parallel()
72+ u := newCIUnit(t, nil)
73+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
74+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
75+ eleven := make([]domain.CIAnnotation, 11)
76+ for i := range eleven {
77+ eleven[i] = warn(3, "m")
78+ }
79+ for name, anns := range map[string][]domain.CIAnnotation{
80+ "eleven": eleven,
81+ "too long": {warn(1, strings.Repeat("x", 4097))},
82+ "no message": {warn(1, "")},
83+ } {
84+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, anns)
85+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
86+ }
87+}
88+
89+func TestResultsAreRefusedWithAnotherJobsToken(t *testing.T) {
90+ t.Parallel()
91+ u := newCIUnit(t, nil)
92+ other := ciJob(t, domain.CIPhaseRunning, otherJobSecret)
93+ other.ID = "7-1-0"
94+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(otherJobSecret), mock.Anything).Return(other, nil)
95+ ctx := context.Background()
96+
97+ err := u.ci.UpdateJob(ctx, otherJobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")})
98+ assert.ErrorIs(t, err, domain.ErrForbidden)
99+ err = u.ci.UploadSummary(ctx, otherJobSecret, jobIDOne, 1, "x")
100+ assert.ErrorIs(t, err, domain.ErrForbidden)
101+ _, err = u.ci.CompleteJob(ctx, otherJobSecret, ports.CompleteJobParams{JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "x")}})
102+ assert.ErrorIs(t, err, domain.ErrForbidden)
103+
104+ // Not a job token at all.
105+ for _, secret := range []string{"", "ermiterun_" + strings.Repeat("a", 43), "ermite_" + strings.Repeat("a", 43)} {
106+ assert.ErrorIs(t, u.ci.UploadSummary(ctx, secret, jobIDOne, 1, "x"), domain.ErrUnauthenticated, secret)
107+ }
108+}
109+
110+func TestEndedJobsRejectAnnotationsAndSummaries(t *testing.T) {
111+ t.Parallel()
112+ u := newCIUnit(t, nil)
113+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
114+ j.Conclusion, j.Error = domain.CICancelled, "stopped"
115+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
116+ ctx := context.Background()
117+
118+ for name, err := range map[string]error{
119+ "UpdateJob": u.ci.UpdateJob(ctx, jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")}),
120+ "UploadSummary": u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, "x"),
121+ } {
122+ ended, ok := domain.IsJobEnded(err)
123+ require.True(t, ok, name)
124+ assert.Equal(t, domain.CICancelled, ended.Conclusion, name)
125+ assert.Equal(t, "stopped", ended.Message, name)
126+ }
127+
128+ // CompleteJob on an ended job returns the stored result and stores nothing,
129+ // whatever the annotations: the mocks expect no read and no write.
130+ res, err := u.ci.CompleteJob(ctx, jobSecret, ports.CompleteJobParams{
131+ JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "ignored")},
132+ })
133+ require.NoError(t, err)
134+ assert.False(t, res.Applied)
135+ assert.Equal(t, domain.CICancelled, res.Conclusion)
136+}
137+
138+func TestUploadSummaryRules(t *testing.T) {
139+ t.Parallel()
140+ u := newCIUnit(t, nil)
141+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
142+ u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
143+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
144+ ctx := context.Background()
145+ ref := ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
146+
147+ for name, tc := range map[string]struct {
148+ step int32
149+ md string
150+ }{
151+ "step 0": {0, "x"},
152+ "step 1001": {1001, "x"},
153+ "1 MiB and a byte": {1, strings.Repeat("x", 1<<20+1)},
154+ "invalid UTF-8": {1, "a\xffb"},
155+ "negative": {-1, "x"},
156+ } {
157+ assert.ErrorIs(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, tc.step, tc.md), domain.ErrInvalid, name)
158+ }
159+
160+ max := strings.Repeat("x", 1<<20)
161+ u.sums.EXPECT().Put(mock.Anything, ref, int32(1), max).Return(nil).Once()
162+ u.sums.EXPECT().Put(mock.Anything, ref, int32(1000), "").Return(nil).Once()
163+ require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, max), "exactly 1 MiB")
164+ require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1000, ""), "empty removes")
165+}
166+
167+// The summaries are read through the same access as GetJob, per attempt of the
168+// job, at most 20.
169+func TestGetStepSummariesAndListAnnotations(t *testing.T) {
170+ t.Parallel()
171+ u := newCIUnit(t, nil)
172+ repo := name(t, "proj")
173+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
174+ j.Attempt, j.RunAttempt = 1, 2 // kept from attempt 1 by a re-run
175+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
176+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 2}, nil)
177+ u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, nil)
178+ u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(2)).Return([]domain.CIJob{j}, nil)
179+ u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return([]domain.CIAnnotation{warn(1, "from attempt 1")}, nil)
180+ u.sums.EXPECT().List(mock.Anything, ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}, domain.CIMaxListedSummaries, int64(0)).
181+ Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil)
182+ ctx := context.Background()
183+
184+ anns, err := u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "")
185+ require.NoError(t, err)
186+ require.Len(t, anns.Annotations, 1)
187+ assert.Equal(t, jobIDOne, anns.Annotations[0].JobID)
188+ assert.False(t, anns.Truncated)
189+
190+ sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0, 0)
191+ require.NoError(t, err)
192+ assert.True(t, sums.Truncated)
193+ assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries)
194+
195+ _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 3, "")
196+ assert.ErrorIs(t, err, domain.ErrNotFound, "an attempt that does not exist")
197+ _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "7-9-0")
198+ assert.ErrorIs(t, err, domain.ErrNotFound, "a job that is not of the run")
199+}
200+
201+func TestListAnnotationsIsBoundedAndTruncated(t *testing.T) {
202+ t.Parallel()
203+ u := newCIUnit(t, nil)
204+ repo := name(t, "proj")
205+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
206+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
207+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil)
208+ u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(1)).Return([]domain.CIJob{j}, nil)
209+ many := make([]domain.CIAnnotation, 1001)
210+ for i := range many {
211+ many[i] = warn(int32(i%1000)+1, "m")
212+ }
213+ u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return(many, nil)
214+ got, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
215+ require.NoError(t, err)
216+ assert.Len(t, got.Annotations, 1000)
217+ assert.True(t, got.Truncated)
218+}
219+
220+// A private repository is not readable by an anonymous caller: not found, as
221+// for GetRun.
222+func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) {
223+ t.Parallel()
224+ u := newCIUnit(t, nil)
225+ repo := name(t, "proj")
226+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil)
227+ _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
228+ assert.ErrorIs(t, err, domain.ErrNotFound)
229+ _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 0)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
232+
233+// The byte budget of a reader reaches the store, and a negative one means none.
234+func TestGetStepSummariesPassesTheBudget(t *testing.T) {
235+ t.Parallel()
236+ u := newCIUnit(t, nil)
237+ repo := name(t, "proj")
238+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
239+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
240+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil)
241+ u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(1), jobIDOne).Return(j, nil)
242+ ref := ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}
243+ u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(4096)).
244+ Return([]domain.CIStepSummary{{Step: 1, Markdown: "a"}, {Step: 2, Omitted: true}}, false, nil).Once()
245+ u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(0)).Return(nil, false, nil).Once()
246+
247+ got, err := u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 4096)
248+ require.NoError(t, err)
249+ assert.True(t, got.Summaries[1].Omitted)
250+ _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, -5)
251+ require.NoError(t, err)
252+}
M internal/usecase/ci_runner.go +52 -16
@@ -321,10 +321,11 @@ func sameAssignment(was, cur domain.CIJob) error {
321321 return nil
322322 }
323323
324-// saveJob writes one job (its steps changed) and publishes JobUpdated. The
325-// status of the run does not depend on steps.
326-func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {
327- v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}})
324+// saveJob writes one job (its steps changed) with the annotations that change
325+// with it, and publishes JobUpdated. The status of the run does not depend on
326+// steps.
327+func (c *CI) saveJob(ctx context.Context, j *domain.CIJob, anns ...ports.CIStepAnnotations) error {
328+ v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}, Annotations: anns})
328329 if err != nil {
329330 return fmt.Errorf("write job %s: %w", j.ID, err)
330331 }
@@ -341,8 +342,10 @@ func logRef(j domain.CIJob) ports.CILogRef {
341342 return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
342343 }
343344
344-// UpdateJob applies the steps of the request whole or not at all.
345-func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate) error {
345+// UpdateJob applies the steps and the annotations of the request whole or not
346+// at all. The annotations of each step they name replace those stored, whether
347+// the step is completed or not.
348+func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate, annotations []domain.CIAnnotation) error {
346349 j, err := c.jobFor(ctx, secret, jobID)
347350 if err != nil {
348351 return err
@@ -353,16 +356,26 @@ func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.
353356 if err := checkStepUpdates(steps); err != nil {
354357 return err
355358 }
359+ if err := domain.CheckAnnotations(annotations); err != nil {
360+ return err
361+ }
356362 return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
357363 if cur.Ended() {
358364 return endedError(*cur)
359365 }
360366 next, changed, err := applySteps(cur.Steps, steps)
361- if err != nil || !changed {
367+ if err != nil {
362368 return err
363369 }
370+ writes, err := c.annotationWrites(ctx, *cur, annotations)
371+ if err != nil {
372+ return err
373+ }
374+ if !changed && len(writes) == 0 {
375+ return nil
376+ }
364377 cur.Steps = next
365- return c.saveJob(ctx, cur)
378+ return c.saveJob(ctx, cur, writes...)
366379 })
367380 }
368381
@@ -377,12 +390,12 @@ func checkStepUpdates(steps []ports.StepUpdate) error {
377390 }
378391 switch s.State {
379392 case domain.CIStepPending:
380- if s.Conclusion != domain.CINoConclusion {
381- return fmt.Errorf("%w: step %d is not completed: it has no conclusion", domain.ErrInvalid, s.Number)
393+ if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion {
394+ return fmt.Errorf("%w: step %d is not completed: it has no conclusion or outcome", domain.ErrInvalid, s.Number)
382395 }
383396 case domain.CIStepInProgress:
384- if s.Conclusion != domain.CINoConclusion || s.StartedAt.IsZero() {
385- return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion is not allowed", domain.ErrInvalid, s.Number)
397+ if s.Conclusion != domain.CINoConclusion || s.Outcome != domain.CINoConclusion || s.StartedAt.IsZero() {
398+ return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion and outcome are not allowed", domain.ErrInvalid, s.Number)
386399 }
387400 case domain.CIStepCompleted:
388401 switch s.Conclusion {
@@ -390,6 +403,11 @@ func checkStepUpdates(steps []ports.StepUpdate) error {
390403 default:
391404 return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
392405 }
406+ switch s.Outcome {
407+ case domain.CINoConclusion, domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped:
408+ default:
409+ return fmt.Errorf("%w: step %d is completed: its outcome must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
410+ }
393411 if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped {
394412 return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number)
395413 }
@@ -413,21 +431,22 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep,
413431 i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number })
414432 if i < 0 {
415433 out = append(out, domain.CIStep{
416- Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
434+ Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, Outcome: u.Outcome,
435+ StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
417436 })
418437 changed = true
419438 continue
420439 }
421440 cur := out[i]
422441 next := cur
423- next.Name, next.State, next.Conclusion = name, u.State, u.Conclusion
442+ next.Name, next.State, next.Conclusion, next.Outcome = name, u.State, u.Conclusion, u.Outcome
424443 next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt
425444 if cur.Name != "" && name == "" {
426445 next.Name = cur.Name // an empty name never erases one
427446 }
428447 switch {
429448 case cur.State == domain.CIStepCompleted:
430- same := next.State == cur.State && next.Conclusion == cur.Conclusion && next.Name == cur.Name &&
449+ same := next.State == cur.State && next.Conclusion == cur.Conclusion && effectiveOutcome(next) == effectiveOutcome(cur) && next.Name == cur.Name &&
431450 next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt)
432451 if !same {
433452 return nil, false, fmt.Errorf("%w: step %d is completed: it cannot change", domain.ErrPreconditionFailed, u.Number)
@@ -444,8 +463,17 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep,
444463 return out, changed, nil
445464 }
446465
466+// effectiveOutcome is the outcome of a step, read as its conclusion when the
467+// runner did not report one.
468+func effectiveOutcome(s domain.CIStep) domain.CIConclusion {
469+ if s.Outcome == domain.CINoConclusion {
470+ return s.Conclusion
471+ }
472+ return s.Outcome
473+}
474+
447475 func stepsEqual(a, b domain.CIStep) bool {
448- return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion &&
476+ return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion && a.Outcome == b.Outcome &&
449477 a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines
450478 }
451479
@@ -731,6 +759,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
731759 if err := checkStepUpdates(params.Steps); err != nil {
732760 return ports.CompleteJobResult{}, err
733761 }
762+ if err := domain.CheckAnnotations(params.Annotations); err != nil {
763+ return ports.CompleteJobResult{}, err
764+ }
734765 var res ports.CompleteJobResult
735766 err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
736767 cur := rs.find(j.ID)
@@ -748,6 +779,11 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple
748779 if err != nil {
749780 return err
750781 }
782+ writes, err := c.annotationWrites(ctx, *cur, params.Annotations)
783+ if err != nil {
784+ return err
785+ }
786+ rs.annotations = append(rs.annotations, writes...)
751787 cur.Steps = steps
752788 cur.Outputs = params.Outputs
753789 msg := ""
M internal/usecase/ci_runner_unit_test.go +13 -4
@@ -24,6 +24,7 @@ import (
2424 type ciUnit struct {
2525 store *mocks.CIStore
2626 logs *mocks.CILogStore
27+ sums *mocks.CISummaryStore
2728 meta *mocks.RepoMetaStore
2829 bus *mocks.EventBus
2930 clock *fakeClock
@@ -37,11 +38,11 @@ func (c *fakeClock) Now() time.Time { return c.t }
3738 func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit {
3839 t.Helper()
3940 u := &ciUnit{
40- store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t),
41+ store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), sums: mocks.NewCISummaryStore(t), meta: mocks.NewRepoMetaStore(t),
4142 bus: mocks.NewEventBus(t), clock: &fakeClock{t: now},
4243 }
4344 cfg := usecase.CIConfig{
44- Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
45+ Store: u.store, Logs: u.logs, Summaries: u.sums, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
4546 Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test",
4647 }
4748 if tweak != nil {
@@ -83,7 +84,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
8384 "UpdateJob": func(u *ciUnit) error {
8485 return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{
8586 Number: 1, State: domain.CIStepInProgress, StartedAt: now,
86- }})
87+ }}, nil)
8788 },
8889 "AppendLog": func(u *ciUnit) error {
8990 _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
@@ -122,7 +123,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
122123 ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled
123124 u.expectToken(jobSecret, j)
124125 u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil)
125- err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}})
126+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}}, nil)
126127 got, ok := domain.IsJobEnded(err)
127128 require.True(t, ok, "%v", err)
128129 assert.Equal(t, domain.CICancelled, got.Conclusion)
@@ -311,6 +312,10 @@ func TestSweep(t *testing.T) {
311312 ctx, out := logCapture(t)
312313 u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil)
313314 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once()
315+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once()
316+ u.sums.EXPECT().RunIDs(mock.Anything).Return([]int64{6, 8}, nil)
317+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{6, 8}).Return(nil).Once()
318+ u.store.EXPECT().GetRunSource(mock.Anything, int64(8)).Return(domain.CIRunSource{}, domain.ErrNotFound)
314319 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil)
315320 u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil)
316321 u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound)
@@ -337,6 +342,8 @@ func TestSweep(t *testing.T) {
337342 ctx, out := logCapture(t)
338343 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil)
339344 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(errors.New("permission denied")).Once()
345+ u.sums.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(nil).Once()
346+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil)
340347 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
341348 u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound)
342349 u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once()
@@ -353,6 +360,7 @@ func TestSweep(t *testing.T) {
353360 ctx, out := logCapture(t)
354361 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
355362 u.logs.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error"))
363+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error"))
356364 u.ci.Sweep(ctx)
357365 assert.Contains(t, out.String(), "list the log directories")
358366 })
@@ -363,6 +371,7 @@ func TestSweep(t *testing.T) {
363371 ctx, out := logCapture(t)
364372 u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
365373 u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
374+ u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil)
366375 u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed"))
367376 u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing
368377 assert.Contains(t, out.String(), "look up a run")
M internal/usecase/ci_watch.go +21 -7
@@ -185,6 +185,8 @@ func itoa(n int64) string { return strconv.FormatInt(n, 10) }
185185 func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) {
186186 ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled")
187187 _ = c.cfg.Logs.Reset(ctx, logRef(*j))
188+ _ = c.cfg.Summaries.RemoveJob(ctx, logRef(*j))
189+ rs.clear = append(rs.clear, logRef(*j))
188190 j.AssignCancels++
189191 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190192 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
@@ -252,14 +254,25 @@ func (c *CI) sweep(ctx context.Context) {
252254 if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil {
253255 log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs")
254256 }
257+ if err := c.cfg.Summaries.RemoveRuns(ctx, ids); err != nil {
258+ log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the summaries of the old runs")
259+ }
255260 for _, id := range ids {
256261 c.locks.Delete(id)
257262 }
258263 log.Info().Int("runs", len(ids)).Msg("ci: old runs removed")
259264 }
260- onDisk, err := c.cfg.Logs.RunIDs(ctx)
265+ c.removeOrphans(ctx, "logs", "log directories", c.cfg.Logs.RunIDs, c.cfg.Logs.RemoveRuns)
266+ c.removeOrphans(ctx, "summaries", "summary directories", c.cfg.Summaries.RunIDs, c.cfg.Summaries.RemoveRuns)
267+}
268+
269+// removeOrphans deletes what a file store (logs, summaries) holds for runs that
270+// no longer exist, for example a repository deleted while the server was down.
271+func (c *CI) removeOrphans(ctx context.Context, what, dirs string, list func(context.Context) ([]int64, error), remove func(context.Context, []int64) error) {
272+ log := ctxLog(ctx)
273+ onDisk, err := list(ctx)
261274 if err != nil {
262- log.Error().Err(err).Msg("ci: list the log directories")
275+ log.Error().Err(err).Msg("ci: list the " + dirs)
263276 return
264277 }
265278 var orphans []int64
@@ -268,12 +281,12 @@ func (c *CI) sweep(ctx context.Context) {
268281 case errors.Is(err, domain.ErrNotFound):
269282 orphans = append(orphans, id)
270283 case err != nil:
271- log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a log directory")
284+ log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a " + what + " directory")
272285 }
273286 }
274287 if len(orphans) > 0 {
275- if err := c.cfg.Logs.RemoveRuns(ctx, orphans); err != nil {
276- log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the logs of runs that no longer exist")
288+ if err := remove(ctx, orphans); err != nil {
289+ log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the " + what + " of runs that no longer exist")
277290 }
278291 }
279292 }
@@ -284,10 +297,11 @@ func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
284297 return c.cfg.Store.RunIDs(ctx, repo)
285298 }
286299
287-// RemoveLogs deletes the logs of runs of a repository that was deleted.
300+// RemoveLogs deletes the logs and the summaries of runs of a repository that
301+// was deleted.
288302 func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error {
289303 for _, id := range runIDs {
290304 c.locks.Delete(id)
291305 }
292- return c.cfg.Logs.RemoveRuns(ctx, runIDs)
306+ return errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs))
293307 }
M proto/ermite/storage/ci.proto +31 -0
@@ -135,6 +135,9 @@ message CIStep {
135135 // count of the job: it is never stored.
136136 int64 log_start = 7;
137137 bool has_lines = 9;
138+ // How the step ended before continue-on-error was applied; unspecified for
139+ // a step stored before the field existed (read it as conclusion).
140+ CIConclusion outcome = 11;
138141 }
139142
140143 // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
@@ -260,3 +263,31 @@ message CIActionResolution {
260263 // 1 = github.com, 2 = this server (the numbers of ActionSource).
261264 int32 source = 4;
262265 }
266+
267+// CIAnnotationLevel is the severity of an annotation.
268+enum CIAnnotationLevel {
269+ CI_ANNOTATION_LEVEL_UNSPECIFIED = 0;
270+ CI_ANNOTATION_LEVEL_NOTICE = 1;
271+ CI_ANNOTATION_LEVEL_WARNING = 2;
272+ CI_ANNOTATION_LEVEL_FAILURE = 3;
273+}
274+
275+// CIAnnotation is a message attached to a step of a job.
276+message CIAnnotation {
277+ CIAnnotationLevel level = 1;
278+ string title = 2;
279+ string message = 3;
280+ string file = 4;
281+ int32 start_line = 5;
282+ int32 end_line = 6;
283+ int32 start_column = 7;
284+ int32 end_column = 8;
285+}
286+
287+// CIStepAnnotations holds all the annotations of one step of one execution of
288+// a job, in the order the step produced them. It is stored under
289+// "annot/<run:020d>/<job attempt:010d>/<job id>/<step:010d>"; a step without
290+// annotation has no key. The runner replaces the whole value at each report.
291+message CIStepAnnotations {
292+ repeated CIAnnotation annotations = 1;
293+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close