Merge pull request 'feat(ci): store and show annotations, step summaries and step outcomes' (#23) from feat/ci2-results into main
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 | |||
| 126 | 126 | ||
| 127 | 127 | 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. | |
| 128 | 128 | ||
| 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. | |
| 130 | 130 | ||
| 131 | 131 | ## Web sign-in | |
| 132 | 132 | ||
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 ( | |||
| 38 | 38 | // ciactive/<job key> job assigned or running | |
| 39 | 39 | // jobtoken/<hash> job token (value: job key) | |
| 40 | 40 | // | |
| 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. | |
| 42 | 43 | const ( | |
| 43 | 44 | runPrefix = "run/" | |
| 44 | 45 | runSrcPrefix = "runsrc/" | |
| @@ -315,7 +316,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 315 | 316 | steps = append(steps, &storagepb.CIStep{ | |
| 316 | 317 | Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion), | |
| 317 | 318 | 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), | |
| 319 | 320 | }) | |
| 320 | 321 | } | |
| 321 | 322 | pb := &storagepb.CIJob{ | |
| @@ -367,7 +368,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 367 | 368 | j.Steps = append(j.Steps, domain.CIStep{ | |
| 368 | 369 | Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()), | |
| 369 | 370 | 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()), | |
| 371 | 372 | }) | |
| 372 | 373 | } | |
| 373 | 374 | return j, nil | |
| @@ -510,6 +511,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||
| 510 | 511 | } | |
| 511 | 512 | } | |
| 512 | 513 | c.stageRepoTokens(ctx, b, w, guarded) | |
| 514 | + | c.stageAnnotations(ctx, b, w, guarded) | |
| 513 | 515 | // Checks are derived from the jobs, written in the same batch and under | |
| 514 | 516 | // the lock of their run: they are upserted without a precondition. | |
| 515 | 517 | for _, ck := range w.Checks { | |
| @@ -836,6 +838,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l | |||
| 836 | 838 | func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte { | |
| 837 | 839 | keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)} | |
| 838 | 840 | keys = append(keys, repoTokenKeys(ctx, snap, runID)...) | |
| 841 | + | keys = append(keys, annotationKeys(ctx, snap, runID)...) | |
| 839 | 842 | for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) { | |
| 840 | 843 | keys = append(keys, slices.Clone(key)) | |
| 841 | 844 | 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 ( | |||
| 25 | 25 | // bounded: a diff has its own limits and blobs are streamed. | |
| 26 | 26 | const MaxRequestBytes = 1 << 20 | |
| 27 | 27 | ||
| 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 | + | ||
| 28 | 33 | // Features are the optional capabilities of the contract that this server | |
| 29 | 34 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 30 | 35 | // 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"} | |
| 32 | 37 | ||
| 33 | 38 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 34 | 39 | const FeaturePasskeys = "passkeys" | |
| @@ -100,7 +105,8 @@ func New(cfg Config) *API { | |||
| 100 | 105 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 101 | 106 | } | |
| 102 | 107 | 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...)) | |
| 104 | 110 | } | |
| 105 | 111 | if cfg.Runs != nil { | |
| 106 | 112 | 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 | |||
| 105 | 105 | if err != nil { | |
| 106 | 106 | return nil, s.failed(ctx, err) | |
| 107 | 107 | } | |
| 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 { | |
| 109 | 113 | return nil, s.failed(ctx, err) | |
| 110 | 114 | } | |
| 111 | 115 | return connect.NewResponse(&pb.UpdateJobResponse{}), nil | |
| @@ -162,9 +166,13 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb | |||
| 162 | 166 | if err != nil { | |
| 163 | 167 | return nil, s.failed(ctx, err) | |
| 164 | 168 | } | |
| 169 | + | anns, err := annotationsFromPB(req.Msg.GetAnnotations()) | |
| 170 | + | if err != nil { | |
| 171 | + | return nil, s.failed(ctx, err) | |
| 172 | + | } | |
| 165 | 173 | res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{ | |
| 166 | 174 | 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, | |
| 168 | 176 | }) | |
| 169 | 177 | if err != nil { | |
| 170 | 178 | return nil, s.failed(ctx, err) | |
| @@ -190,10 +198,12 @@ func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request | |||
| 190 | 198 | return connect.NewResponse(out), nil | |
| 191 | 199 | } | |
| 192 | 200 | ||
| 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 | |
| 197 | 207 | } | |
| 198 | 208 | ||
| 199 | 209 | // runService implements ermiteconnect.RunServiceHandler. | |
| @@ -334,13 +344,36 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R | |||
| 334 | 344 | return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil | |
| 335 | 345 | } | |
| 336 | 346 | ||
| 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 | |
| 342 | 362 | } | |
| 343 | 363 | ||
| 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 | |
| 346 | 379 | } | |
M internal/adapters/rpc/ci_convert.go +67 -2
| @@ -149,13 +149,26 @@ func runToPB(r domain.CIRun) *pb.Run { | |||
| 149 | 149 | } | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 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 | + | ||
| 152 | 165 | func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step { | |
| 153 | 166 | ranges := domain.StepRanges(steps, lines) | |
| 154 | 167 | out := make([]*pb.Step, 0, len(steps)) | |
| 155 | 168 | for i, s := range steps { | |
| 156 | 169 | out = append(out, &pb.Step{ | |
| 157 | 170 | 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), | |
| 159 | 172 | LogStart: ranges[i].Start, LogEnd: ranges[i].End, | |
| 160 | 173 | }) | |
| 161 | 174 | } | |
| @@ -231,8 +244,12 @@ func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) { | |||
| 231 | 244 | if err != nil { | |
| 232 | 245 | return nil, err | |
| 233 | 246 | } | |
| 247 | + | outcome, err := conclusionFromPB(s.GetOutcome()) | |
| 248 | + | if err != nil { | |
| 249 | + | return nil, err | |
| 250 | + | } | |
| 234 | 251 | 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, | |
| 236 | 253 | StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()), | |
| 237 | 254 | }) | |
| 238 | 255 | } | |
| @@ -250,3 +267,51 @@ func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated { | |||
| 250 | 267 | func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated { | |
| 251 | 268 | return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}} | |
| 252 | 269 | } | |
| 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 ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 16 | 17 | "bnema.dev/ermite/internal/domain" | |
| 17 | 18 | "bnema.dev/ermite/internal/ports" | |
| 18 | 19 | "bnema.dev/ermite/internal/ports/mocks" | |
| @@ -99,3 +100,119 @@ func TestRunnerServiceErrors(t *testing.T) { | |||
| 99 | 100 | requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated)) | |
| 100 | 101 | requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail | |
| 101 | 102 | } | |
| 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) { | |||
| 62 | 62 | <div id="run-body"> | |
| 63 | 63 | @RunBody(p) | |
| 64 | 64 | </div> | |
| 65 | + | @RunSummaries(p.Summaries, p.SummariesMore) | |
| 65 | 66 | } | |
| 66 | 67 | } | |
| 67 | 68 | ||
| @@ -78,6 +79,12 @@ templ RunBody(p RunPage) { | |||
| 78 | 79 | if p.Error != "" { | |
| 79 | 80 | <pre class="run-err">{ p.Error }</pre> | |
| 80 | 81 | } | |
| 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 | + | } | |
| 81 | 88 | <ul class="jobs"> | |
| 82 | 89 | for _, j := range p.Jobs { | |
| 83 | 90 | <li id={ "job-" + j.ID }> | |
| @@ -107,6 +114,104 @@ templ RunBody(p RunPage) { | |||
| 107 | 114 | </ul> | |
| 108 | 115 | } | |
| 109 | 116 | ||
| 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 | + | ||
| 110 | 215 | templ runRail(p RunPage) { | |
| 111 | 216 | <section aria-labelledby="r-run"> | |
| 112 | 217 | <h2 id="r-run">Run</h2> | |
| @@ -186,6 +291,7 @@ templ Job(l Layout, p JobPage) { | |||
| 186 | 291 | @Shell(l, jobRail(p)) { | |
| 187 | 292 | @LiveSlot("job-notice") | |
| 188 | 293 | @JobHead(p.JobRow, p.Total) | |
| 294 | + | @JobAnnotations(p.Annotations, p.AnnotationsMore) | |
| 189 | 295 | <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))) }> | |
| 190 | 296 | if p.Note != "" { | |
| 191 | 297 | <p class="dim">{ p.Note }</p> | |
M internal/adapters/web/actions_log.go +3 -0
| @@ -51,6 +51,9 @@ type JobPage struct { | |||
| 51 | 51 | // StreamHref is the live feed of the lines (logStream). | |
| 52 | 52 | StreamHref string | |
| 53 | 53 | Note string | |
| 54 | + | // Annotations are those of the job, shown above the log. | |
| 55 | + | Annotations []AnnotationView | |
| 56 | + | AnnotationsMore bool | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | // 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 { | |||
| 173 | 173 | Latest int32 | |
| 174 | 174 | Attempts []AttemptLink | |
| 175 | 175 | 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 | |
| 177 | 187 | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| 178 | 188 | CancelAction, RerunAction, RerunFailedAction string | |
| 179 | 189 | CanRerunFailed bool | |
| @@ -284,6 +294,11 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 284 | 294 | if err != nil { | |
| 285 | 295 | return nil, 0, err | |
| 286 | 296 | } | |
| 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 | + | } | |
| 287 | 302 | l := s.repoLayout(ctx, d, "actions") | |
| 288 | 303 | l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance | |
| 289 | 304 | 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 | |||
| 303 | 318 | run := detail.Run | |
| 304 | 319 | p := RunPage{ | |
| 305 | 320 | 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, | |
| 307 | 322 | } | |
| 308 | 323 | for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- { | |
| 309 | 324 | p.Attempts = append(p.Attempts, AttemptLink{ | |
| @@ -318,6 +333,11 @@ func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int | |||
| 318 | 333 | } | |
| 319 | 334 | p.Jobs = append(p.Jobs, row) | |
| 320 | 335 | } | |
| 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 | + | } | |
| 321 | 341 | if SignedIn(ctx) && run.Attempt == detail.LatestAttempt { | |
| 322 | 342 | base := runHref(d.name(), id) | |
| 323 | 343 | if p.Active { | |
| @@ -382,6 +402,9 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 382 | 402 | p.RawHref += "?attempt=" + strconv.Itoa(int(attempt)) | |
| 383 | 403 | } | |
| 384 | 404 | 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 | + | } | |
| 385 | 408 | p.StreamHref = jobHref(d.name(), id, jobID) + "/stream" | |
| 386 | 409 | lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines) | |
| 387 | 410 | 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 <b>two</b>", "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 <script>", "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
| @@ -495,6 +495,10 @@ func RunDetail(l Layout, p RunPage) templ.Component { | |||
| 495 | 495 | if templ_7745c5c3_Err != nil { | |
| 496 | 496 | return templ_7745c5c3_Err | |
| 497 | 497 | } | |
| 498 | + | templ_7745c5c3_Err = RunSummaries(p.Summaries, p.SummariesMore).Render(ctx, templ_7745c5c3_Buffer) | |
| 499 | + | if templ_7745c5c3_Err != nil { | |
| 500 | + | return templ_7745c5c3_Err | |
| 501 | + | } | |
| 498 | 502 | return nil | |
| 499 | 503 | }) | |
| 500 | 504 | templ_7745c5c3_Err = Shell(l, runRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var28), templ_7745c5c3_Buffer) | |
| @@ -580,7 +584,7 @@ func RunBody(p RunPage) templ.Component { | |||
| 580 | 584 | var templ_7745c5c3_Var31 string | |
| 581 | 585 | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(p.Workflow) | |
| 582 | 586 | if templ_7745c5c3_Err != nil { | |
| 583 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 64} | |
| 587 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 78, Col: 64} | |
| 584 | 588 | } | |
| 585 | 589 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 586 | 590 | if templ_7745c5c3_Err != nil { | |
| @@ -593,7 +597,7 @@ func RunBody(p RunPage) templ.Component { | |||
| 593 | 597 | var templ_7745c5c3_Var32 string | |
| 594 | 598 | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number) | |
| 595 | 599 | if templ_7745c5c3_Err != nil { | |
| 596 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 103} | |
| 600 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 78, Col: 103} | |
| 597 | 601 | } | |
| 598 | 602 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 599 | 603 | if templ_7745c5c3_Err != nil { | |
| @@ -611,7 +615,7 @@ func RunBody(p RunPage) templ.Component { | |||
| 611 | 615 | var templ_7745c5c3_Var33 string | |
| 612 | 616 | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error) | |
| 613 | 617 | if templ_7745c5c3_Err != nil { | |
| 614 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 79, Col: 32} | |
| 618 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 80, Col: 32} | |
| 615 | 619 | } | |
| 616 | 620 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 617 | 621 | if templ_7745c5c3_Err != nil { | |
| @@ -622,157 +626,184 @@ func RunBody(p RunPage) templ.Component { | |||
| 622 | 626 | return templ_7745c5c3_Err | |
| 623 | 627 | } | |
| 624 | 628 | } | |
| 625 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<ul class=\"jobs\">") | |
| 626 | - | if templ_7745c5c3_Err != nil { | |
| 627 | - | return templ_7745c5c3_Err | |
| 628 | - | } | |
| 629 | - | for _, j := range p.Jobs { | |
| 630 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<li id=\"") | |
| 629 | + | if len(p.Annotations) > 0 { | |
| 630 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<section class=\"annots\" aria-labelledby=\"run-annots\"><h3 id=\"run-annots\">Annotations <span class=\"count\">") | |
| 631 | 631 | if templ_7745c5c3_Err != nil { | |
| 632 | 632 | return templ_7745c5c3_Err | |
| 633 | 633 | } | |
| 634 | 634 | var templ_7745c5c3_Var34 string | |
| 635 | - | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID) | |
| 635 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Annotations))) | |
| 636 | 636 | if templ_7745c5c3_Err != nil { | |
| 637 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 83, Col: 25} | |
| 637 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 84, Col: 81} | |
| 638 | 638 | } | |
| 639 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | |
| 639 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 640 | 640 | if templ_7745c5c3_Err != nil { | |
| 641 | 641 | return templ_7745c5c3_Err | |
| 642 | 642 | } | |
| 643 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><a class=\"job-h\" href=\"") | |
| 643 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span></h3>") | |
| 644 | 644 | if templ_7745c5c3_Err != nil { | |
| 645 | 645 | return templ_7745c5c3_Err | |
| 646 | 646 | } | |
| 647 | - | var templ_7745c5c3_Var35 templ.SafeURL | |
| 648 | - | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href)) | |
| 647 | + | templ_7745c5c3_Err = AnnotationList(p.Annotations, p.AnnotationsMore, false).Render(ctx, templ_7745c5c3_Buffer) | |
| 649 | 648 | if templ_7745c5c3_Err != nil { | |
| 650 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 84, Col: 49} | |
| 649 | + | return templ_7745c5c3_Err | |
| 651 | 650 | } | |
| 652 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 651 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>") | |
| 653 | 652 | if templ_7745c5c3_Err != nil { | |
| 654 | 653 | return templ_7745c5c3_Err | |
| 655 | 654 | } | |
| 656 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">") | |
| 655 | + | } | |
| 656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<ul class=\"jobs\">") | |
| 657 | + | if templ_7745c5c3_Err != nil { | |
| 658 | + | return templ_7745c5c3_Err | |
| 659 | + | } | |
| 660 | + | for _, j := range p.Jobs { | |
| 661 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li id=\"") | |
| 657 | 662 | if templ_7745c5c3_Err != nil { | |
| 658 | 663 | return templ_7745c5c3_Err | |
| 659 | 664 | } | |
| 660 | - | templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 665 | + | var templ_7745c5c3_Var35 string | |
| 666 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID) | |
| 667 | + | if templ_7745c5c3_Err != nil { | |
| 668 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 90, Col: 25} | |
| 669 | + | } | |
| 670 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) | |
| 661 | 671 | if templ_7745c5c3_Err != nil { | |
| 662 | 672 | return templ_7745c5c3_Err | |
| 663 | 673 | } | |
| 664 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"j-name\">") | |
| 674 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"><a class=\"job-h\" href=\"") | |
| 665 | 675 | if templ_7745c5c3_Err != nil { | |
| 666 | 676 | return templ_7745c5c3_Err | |
| 667 | 677 | } | |
| 668 | - | var templ_7745c5c3_Var36 string | |
| 669 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 678 | + | var templ_7745c5c3_Var36 templ.SafeURL | |
| 679 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href)) | |
| 670 | 680 | if templ_7745c5c3_Err != nil { | |
| 671 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 86, Col: 34} | |
| 681 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 91, Col: 49} | |
| 672 | 682 | } | |
| 673 | 683 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 674 | 684 | if templ_7745c5c3_Err != nil { | |
| 675 | 685 | return templ_7745c5c3_Err | |
| 676 | 686 | } | |
| 677 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span> <span class=\"dim\">") | |
| 687 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">") | |
| 688 | + | if templ_7745c5c3_Err != nil { | |
| 689 | + | return templ_7745c5c3_Err | |
| 690 | + | } | |
| 691 | + | templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 692 | + | if templ_7745c5c3_Err != nil { | |
| 693 | + | return templ_7745c5c3_Err | |
| 694 | + | } | |
| 695 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"j-name\">") | |
| 678 | 696 | if templ_7745c5c3_Err != nil { | |
| 679 | 697 | return templ_7745c5c3_Err | |
| 680 | 698 | } | |
| 681 | 699 | var templ_7745c5c3_Var37 string | |
| 682 | - | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 700 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 683 | 701 | if templ_7745c5c3_Err != nil { | |
| 684 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 32} | |
| 702 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 93, Col: 34} | |
| 685 | 703 | } | |
| 686 | 704 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 687 | 705 | if templ_7745c5c3_Err != nil { | |
| 688 | 706 | return templ_7745c5c3_Err | |
| 689 | 707 | } | |
| 708 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> <span class=\"dim\">") | |
| 709 | + | if templ_7745c5c3_Err != nil { | |
| 710 | + | return templ_7745c5c3_Err | |
| 711 | + | } | |
| 690 | 712 | var templ_7745c5c3_Var38 string | |
| 691 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 713 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 692 | 714 | if templ_7745c5c3_Err != nil { | |
| 693 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 39} | |
| 715 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 94, Col: 32} | |
| 694 | 716 | } | |
| 695 | 717 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 696 | 718 | if templ_7745c5c3_Err != nil { | |
| 697 | 719 | return templ_7745c5c3_Err | |
| 698 | 720 | } | |
| 699 | 721 | var templ_7745c5c3_Var39 string | |
| 700 | - | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 722 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 701 | 723 | if templ_7745c5c3_Err != nil { | |
| 702 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 53} | |
| 724 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 94, Col: 39} | |
| 703 | 725 | } | |
| 704 | 726 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 705 | 727 | if templ_7745c5c3_Err != nil { | |
| 706 | 728 | return templ_7745c5c3_Err | |
| 707 | 729 | } | |
| 708 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></a> ") | |
| 730 | + | var templ_7745c5c3_Var40 string | |
| 731 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 732 | + | if templ_7745c5c3_Err != nil { | |
| 733 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 94, Col: 53} | |
| 734 | + | } | |
| 735 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 736 | + | if templ_7745c5c3_Err != nil { | |
| 737 | + | return templ_7745c5c3_Err | |
| 738 | + | } | |
| 739 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span></a> ") | |
| 709 | 740 | if templ_7745c5c3_Err != nil { | |
| 710 | 741 | return templ_7745c5c3_Err | |
| 711 | 742 | } | |
| 712 | 743 | if j.Runner != "" { | |
| 713 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"mute\">on ") | |
| 744 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"mute\">on ") | |
| 714 | 745 | if templ_7745c5c3_Err != nil { | |
| 715 | 746 | return templ_7745c5c3_Err | |
| 716 | 747 | } | |
| 717 | - | var templ_7745c5c3_Var40 string | |
| 718 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j)) | |
| 748 | + | var templ_7745c5c3_Var41 string | |
| 749 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j)) | |
| 719 | 750 | if templ_7745c5c3_Err != nil { | |
| 720 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 90, Col: 42} | |
| 751 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 97, Col: 42} | |
| 721 | 752 | } | |
| 722 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 753 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 723 | 754 | if templ_7745c5c3_Err != nil { | |
| 724 | 755 | return templ_7745c5c3_Err | |
| 725 | 756 | } | |
| 726 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> ") | |
| 757 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span> ") | |
| 727 | 758 | if templ_7745c5c3_Err != nil { | |
| 728 | 759 | return templ_7745c5c3_Err | |
| 729 | 760 | } | |
| 730 | 761 | } | |
| 731 | 762 | if len(j.Needs) > 0 { | |
| 732 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"mute\">needs ") | |
| 763 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"mute\">needs ") | |
| 733 | 764 | if templ_7745c5c3_Err != nil { | |
| 734 | 765 | return templ_7745c5c3_Err | |
| 735 | 766 | } | |
| 736 | - | var templ_7745c5c3_Var41 string | |
| 737 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs)) | |
| 767 | + | var templ_7745c5c3_Var42 string | |
| 768 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs)) | |
| 738 | 769 | if templ_7745c5c3_Err != nil { | |
| 739 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 93, Col: 50} | |
| 770 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 100, Col: 50} | |
| 740 | 771 | } | |
| 741 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 772 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 742 | 773 | if templ_7745c5c3_Err != nil { | |
| 743 | 774 | return templ_7745c5c3_Err | |
| 744 | 775 | } | |
| 745 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> ") | |
| 776 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span> ") | |
| 746 | 777 | if templ_7745c5c3_Err != nil { | |
| 747 | 778 | return templ_7745c5c3_Err | |
| 748 | 779 | } | |
| 749 | 780 | } | |
| 750 | 781 | if j.Error != "" { | |
| 751 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<p class=\"job-err\">") | |
| 782 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<p class=\"job-err\">") | |
| 752 | 783 | if templ_7745c5c3_Err != nil { | |
| 753 | 784 | return templ_7745c5c3_Err | |
| 754 | 785 | } | |
| 755 | - | var templ_7745c5c3_Var42 string | |
| 756 | - | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 786 | + | var templ_7745c5c3_Var43 string | |
| 787 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 757 | 788 | if templ_7745c5c3_Err != nil { | |
| 758 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 96, Col: 33} | |
| 789 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 103, Col: 33} | |
| 759 | 790 | } | |
| 760 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 791 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 761 | 792 | if templ_7745c5c3_Err != nil { | |
| 762 | 793 | return templ_7745c5c3_Err | |
| 763 | 794 | } | |
| 764 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</p>") | |
| 795 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</p>") | |
| 765 | 796 | if templ_7745c5c3_Err != nil { | |
| 766 | 797 | return templ_7745c5c3_Err | |
| 767 | 798 | } | |
| 768 | 799 | } | |
| 769 | 800 | if len(j.Steps) > 0 { | |
| 770 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<ol class=\"steps\">") | |
| 801 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<ol class=\"steps\">") | |
| 771 | 802 | if templ_7745c5c3_Err != nil { | |
| 772 | 803 | return templ_7745c5c3_Err | |
| 773 | 804 | } | |
| 774 | 805 | for _, s := range j.Steps { | |
| 775 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<li>") | |
| 806 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<li>") | |
| 776 | 807 | if templ_7745c5c3_Err != nil { | |
| 777 | 808 | return templ_7745c5c3_Err | |
| 778 | 809 | } | |
| @@ -780,48 +811,456 @@ func RunBody(p RunPage) templ.Component { | |||
| 780 | 811 | if templ_7745c5c3_Err != nil { | |
| 781 | 812 | return templ_7745c5c3_Err | |
| 782 | 813 | } | |
| 783 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>") | |
| 814 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span>") | |
| 784 | 815 | if templ_7745c5c3_Err != nil { | |
| 785 | 816 | return templ_7745c5c3_Err | |
| 786 | 817 | } | |
| 787 | - | var templ_7745c5c3_Var43 string | |
| 788 | - | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 818 | + | var templ_7745c5c3_Var44 string | |
| 819 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 789 | 820 | if templ_7745c5c3_Err != nil { | |
| 790 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 53} | |
| 821 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 108, Col: 53} | |
| 791 | 822 | } | |
| 792 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 823 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 793 | 824 | if templ_7745c5c3_Err != nil { | |
| 794 | 825 | return templ_7745c5c3_Err | |
| 795 | 826 | } | |
| 796 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> <span class=\"dim\">") | |
| 827 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"dim\">") | |
| 797 | 828 | if templ_7745c5c3_Err != nil { | |
| 798 | 829 | return templ_7745c5c3_Err | |
| 799 | 830 | } | |
| 800 | - | var templ_7745c5c3_Var44 string | |
| 801 | - | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 831 | + | var templ_7745c5c3_Var45 string | |
| 832 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 802 | 833 | if templ_7745c5c3_Err != nil { | |
| 803 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 93} | |
| 834 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 108, Col: 93} | |
| 804 | 835 | } | |
| 805 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 836 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 837 | + | if templ_7745c5c3_Err != nil { | |
| 838 | + | return templ_7745c5c3_Err | |
| 839 | + | } | |
| 840 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></li>") | |
| 841 | + | if templ_7745c5c3_Err != nil { | |
| 842 | + | return templ_7745c5c3_Err | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ol>") | |
| 846 | + | if templ_7745c5c3_Err != nil { | |
| 847 | + | return templ_7745c5c3_Err | |
| 848 | + | } | |
| 849 | + | } | |
| 850 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>") | |
| 851 | + | if templ_7745c5c3_Err != nil { | |
| 852 | + | return templ_7745c5c3_Err | |
| 853 | + | } | |
| 854 | + | } | |
| 855 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</ul>") | |
| 856 | + | if templ_7745c5c3_Err != nil { | |
| 857 | + | return templ_7745c5c3_Err | |
| 858 | + | } | |
| 859 | + | return nil | |
| 860 | + | }) | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | // AnnotationList is a list of annotations: the level as a mark with its word, | |
| 864 | + | // the job and the step, the title and the message, and file:line. Everything | |
| 865 | + | // comes from the workflow and is text. Without grouped, each item names its job (the | |
| 866 | + | // run page); grouped lists the annotations by step instead (the job page). | |
| 867 | + | func AnnotationList(anns []AnnotationView, more bool, grouped bool) templ.Component { | |
| 868 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 869 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 870 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 871 | + | return templ_7745c5c3_CtxErr | |
| 872 | + | } | |
| 873 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 874 | + | if !templ_7745c5c3_IsBuffer { | |
| 875 | + | defer func() { | |
| 876 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 877 | + | if templ_7745c5c3_Err == nil { | |
| 878 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 879 | + | } | |
| 880 | + | }() | |
| 881 | + | } | |
| 882 | + | ctx = templ.InitializeContext(ctx) | |
| 883 | + | templ_7745c5c3_Var46 := templ.GetChildren(ctx) | |
| 884 | + | if templ_7745c5c3_Var46 == nil { | |
| 885 | + | templ_7745c5c3_Var46 = templ.NopComponent | |
| 886 | + | } | |
| 887 | + | ctx = templ.ClearChildren(ctx) | |
| 888 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<ul class=\"annot-list\">") | |
| 889 | + | if templ_7745c5c3_Err != nil { | |
| 890 | + | return templ_7745c5c3_Err | |
| 891 | + | } | |
| 892 | + | for _, a := range anns { | |
| 893 | + | var templ_7745c5c3_Var47 = []any{"annot", "annot-" + a.Kind} | |
| 894 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) | |
| 895 | + | if templ_7745c5c3_Err != nil { | |
| 896 | + | return templ_7745c5c3_Err | |
| 897 | + | } | |
| 898 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<li class=\"") | |
| 899 | + | if templ_7745c5c3_Err != nil { | |
| 900 | + | return templ_7745c5c3_Err | |
| 901 | + | } | |
| 902 | + | var templ_7745c5c3_Var48 string | |
| 903 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var47).String()) | |
| 904 | + | if templ_7745c5c3_Err != nil { | |
| 905 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0} | |
| 906 | + | } | |
| 907 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48) | |
| 908 | + | if templ_7745c5c3_Err != nil { | |
| 909 | + | return templ_7745c5c3_Err | |
| 910 | + | } | |
| 911 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">") | |
| 912 | + | if templ_7745c5c3_Err != nil { | |
| 913 | + | return templ_7745c5c3_Err | |
| 914 | + | } | |
| 915 | + | if grouped && a.StepHead { | |
| 916 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<p class=\"annot-step\">") | |
| 917 | + | if templ_7745c5c3_Err != nil { | |
| 918 | + | return templ_7745c5c3_Err | |
| 919 | + | } | |
| 920 | + | if a.StepHref != "" { | |
| 921 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a href=\"") | |
| 922 | + | if templ_7745c5c3_Err != nil { | |
| 923 | + | return templ_7745c5c3_Err | |
| 924 | + | } | |
| 925 | + | var templ_7745c5c3_Var49 templ.SafeURL | |
| 926 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.StepHref)) | |
| 927 | + | if templ_7745c5c3_Err != nil { | |
| 928 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 128, Col: 42} | |
| 929 | + | } | |
| 930 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 931 | + | if templ_7745c5c3_Err != nil { | |
| 932 | + | return templ_7745c5c3_Err | |
| 933 | + | } | |
| 934 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">") | |
| 935 | + | if templ_7745c5c3_Err != nil { | |
| 936 | + | return templ_7745c5c3_Err | |
| 937 | + | } | |
| 938 | + | var templ_7745c5c3_Var50 string | |
| 939 | + | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step) | |
| 940 | + | if templ_7745c5c3_Err != nil { | |
| 941 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 128, Col: 53} | |
| 942 | + | } | |
| 943 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 944 | + | if templ_7745c5c3_Err != nil { | |
| 945 | + | return templ_7745c5c3_Err | |
| 946 | + | } | |
| 947 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</a>") | |
| 948 | + | if templ_7745c5c3_Err != nil { | |
| 949 | + | return templ_7745c5c3_Err | |
| 950 | + | } | |
| 951 | + | } else { | |
| 952 | + | var templ_7745c5c3_Var51 string | |
| 953 | + | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step) | |
| 954 | + | if templ_7745c5c3_Err != nil { | |
| 955 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 130, Col: 15} | |
| 956 | + | } | |
| 957 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 958 | + | if templ_7745c5c3_Err != nil { | |
| 959 | + | return templ_7745c5c3_Err | |
| 960 | + | } | |
| 961 | + | } | |
| 962 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>") | |
| 963 | + | if templ_7745c5c3_Err != nil { | |
| 964 | + | return templ_7745c5c3_Err | |
| 965 | + | } | |
| 966 | + | } | |
| 967 | + | var templ_7745c5c3_Var52 = []any{"an", "an-" + a.Kind} | |
| 968 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var52...) | |
| 969 | + | if templ_7745c5c3_Err != nil { | |
| 970 | + | return templ_7745c5c3_Err | |
| 971 | + | } | |
| 972 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span class=\"") | |
| 973 | + | if templ_7745c5c3_Err != nil { | |
| 974 | + | return templ_7745c5c3_Err | |
| 975 | + | } | |
| 976 | + | var templ_7745c5c3_Var53 string | |
| 977 | + | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var52).String()) | |
| 978 | + | if templ_7745c5c3_Err != nil { | |
| 979 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0} | |
| 980 | + | } | |
| 981 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | |
| 982 | + | if templ_7745c5c3_Err != nil { | |
| 983 | + | return templ_7745c5c3_Err | |
| 984 | + | } | |
| 985 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" role=\"img\" aria-label=\"") | |
| 986 | + | if templ_7745c5c3_Err != nil { | |
| 987 | + | return templ_7745c5c3_Err | |
| 988 | + | } | |
| 989 | + | var templ_7745c5c3_Var54 string | |
| 990 | + | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level) | |
| 991 | + | if templ_7745c5c3_Err != nil { | |
| 992 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 134, Col: 72} | |
| 993 | + | } | |
| 994 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) | |
| 995 | + | if templ_7745c5c3_Err != nil { | |
| 996 | + | return templ_7745c5c3_Err | |
| 997 | + | } | |
| 998 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" title=\"") | |
| 999 | + | if templ_7745c5c3_Err != nil { | |
| 1000 | + | return templ_7745c5c3_Err | |
| 1001 | + | } | |
| 1002 | + | var templ_7745c5c3_Var55 string | |
| 1003 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.Level) | |
| 1004 | + | if templ_7745c5c3_Err != nil { | |
| 1005 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 134, Col: 90} | |
| 1006 | + | } | |
| 1007 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55) | |
| 1008 | + | if templ_7745c5c3_Err != nil { | |
| 1009 | + | return templ_7745c5c3_Err | |
| 1010 | + | } | |
| 1011 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"></span><div class=\"annot-body\"><p class=\"annot-head\"><span class=\"annot-level\">") | |
| 1012 | + | if templ_7745c5c3_Err != nil { | |
| 1013 | + | return templ_7745c5c3_Err | |
| 1014 | + | } | |
| 1015 | + | var templ_7745c5c3_Var56 string | |
| 1016 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(a.Level) | |
| 1017 | + | if templ_7745c5c3_Err != nil { | |
| 1018 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 137, Col: 41} | |
| 1019 | + | } | |
| 1020 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1021 | + | if templ_7745c5c3_Err != nil { | |
| 1022 | + | return templ_7745c5c3_Err | |
| 1023 | + | } | |
| 1024 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ") | |
| 1025 | + | if templ_7745c5c3_Err != nil { | |
| 1026 | + | return templ_7745c5c3_Err | |
| 1027 | + | } | |
| 1028 | + | if !grouped { | |
| 1029 | + | if a.JobHref != "" { | |
| 1030 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<a href=\"") | |
| 1031 | + | if templ_7745c5c3_Err != nil { | |
| 1032 | + | return templ_7745c5c3_Err | |
| 1033 | + | } | |
| 1034 | + | var templ_7745c5c3_Var57 templ.SafeURL | |
| 1035 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.JobHref)) | |
| 1036 | + | if templ_7745c5c3_Err != nil { | |
| 1037 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 140, Col: 42} | |
| 1038 | + | } | |
| 1039 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1040 | + | if templ_7745c5c3_Err != nil { | |
| 1041 | + | return templ_7745c5c3_Err | |
| 1042 | + | } | |
| 1043 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">") | |
| 1044 | + | if templ_7745c5c3_Err != nil { | |
| 1045 | + | return templ_7745c5c3_Err | |
| 1046 | + | } | |
| 1047 | + | var templ_7745c5c3_Var58 string | |
| 1048 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job) | |
| 1049 | + | if templ_7745c5c3_Err != nil { | |
| 1050 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 140, Col: 52} | |
| 1051 | + | } | |
| 1052 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1053 | + | if templ_7745c5c3_Err != nil { | |
| 1054 | + | return templ_7745c5c3_Err | |
| 1055 | + | } | |
| 1056 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</a>") | |
| 1057 | + | if templ_7745c5c3_Err != nil { | |
| 1058 | + | return templ_7745c5c3_Err | |
| 1059 | + | } | |
| 1060 | + | } else { | |
| 1061 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span>") | |
| 1062 | + | if templ_7745c5c3_Err != nil { | |
| 1063 | + | return templ_7745c5c3_Err | |
| 1064 | + | } | |
| 1065 | + | var templ_7745c5c3_Var59 string | |
| 1066 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(a.Job) | |
| 1067 | + | if templ_7745c5c3_Err != nil { | |
| 1068 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 142, Col: 21} | |
| 1069 | + | } | |
| 1070 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1071 | + | if templ_7745c5c3_Err != nil { | |
| 1072 | + | return templ_7745c5c3_Err | |
| 1073 | + | } | |
| 1074 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span>") | |
| 1075 | + | if templ_7745c5c3_Err != nil { | |
| 1076 | + | return templ_7745c5c3_Err | |
| 1077 | + | } | |
| 1078 | + | } | |
| 1079 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " <span class=\"mute\">") | |
| 1080 | + | if templ_7745c5c3_Err != nil { | |
| 1081 | + | return templ_7745c5c3_Err | |
| 1082 | + | } | |
| 1083 | + | var templ_7745c5c3_Var60 string | |
| 1084 | + | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(a.Step) | |
| 1085 | + | if templ_7745c5c3_Err != nil { | |
| 1086 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 144, Col: 34} | |
| 1087 | + | } | |
| 1088 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1089 | + | if templ_7745c5c3_Err != nil { | |
| 1090 | + | return templ_7745c5c3_Err | |
| 1091 | + | } | |
| 1092 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ") | |
| 1093 | + | if templ_7745c5c3_Err != nil { | |
| 1094 | + | return templ_7745c5c3_Err | |
| 1095 | + | } | |
| 1096 | + | } | |
| 1097 | + | if a.Title != "" { | |
| 1098 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<strong>") | |
| 1099 | + | if templ_7745c5c3_Err != nil { | |
| 1100 | + | return templ_7745c5c3_Err | |
| 1101 | + | } | |
| 1102 | + | var templ_7745c5c3_Var61 string | |
| 1103 | + | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(a.Title) | |
| 1104 | + | if templ_7745c5c3_Err != nil { | |
| 1105 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 147, Col: 24} | |
| 1106 | + | } | |
| 1107 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1108 | + | if templ_7745c5c3_Err != nil { | |
| 1109 | + | return templ_7745c5c3_Err | |
| 1110 | + | } | |
| 1111 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</strong>") | |
| 1112 | + | if templ_7745c5c3_Err != nil { | |
| 1113 | + | return templ_7745c5c3_Err | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</p><p class=\"annot-msg\">") | |
| 1117 | + | if templ_7745c5c3_Err != nil { | |
| 1118 | + | return templ_7745c5c3_Err | |
| 1119 | + | } | |
| 1120 | + | var templ_7745c5c3_Var62 string | |
| 1121 | + | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(a.Message) | |
| 1122 | + | if templ_7745c5c3_Err != nil { | |
| 1123 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 150, Col: 37} | |
| 1124 | + | } | |
| 1125 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1126 | + | if templ_7745c5c3_Err != nil { | |
| 1127 | + | return templ_7745c5c3_Err | |
| 1128 | + | } | |
| 1129 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</p>") | |
| 1130 | + | if templ_7745c5c3_Err != nil { | |
| 1131 | + | return templ_7745c5c3_Err | |
| 1132 | + | } | |
| 1133 | + | if a.Where != "" { | |
| 1134 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<p class=\"annot-where\">") | |
| 1135 | + | if templ_7745c5c3_Err != nil { | |
| 1136 | + | return templ_7745c5c3_Err | |
| 1137 | + | } | |
| 1138 | + | if a.WhereHref != "" { | |
| 1139 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<a href=\"") | |
| 1140 | + | if templ_7745c5c3_Err != nil { | |
| 1141 | + | return templ_7745c5c3_Err | |
| 1142 | + | } | |
| 1143 | + | var templ_7745c5c3_Var63 templ.SafeURL | |
| 1144 | + | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.WhereHref)) | |
| 1145 | + | if templ_7745c5c3_Err != nil { | |
| 1146 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 154, Col: 44} | |
| 1147 | + | } | |
| 1148 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1149 | + | if templ_7745c5c3_Err != nil { | |
| 1150 | + | return templ_7745c5c3_Err | |
| 1151 | + | } | |
| 1152 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\">") | |
| 1153 | + | if templ_7745c5c3_Err != nil { | |
| 1154 | + | return templ_7745c5c3_Err | |
| 1155 | + | } | |
| 1156 | + | var templ_7745c5c3_Var64 string | |
| 1157 | + | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where) | |
| 1158 | + | if templ_7745c5c3_Err != nil { | |
| 1159 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 154, Col: 56} | |
| 1160 | + | } | |
| 1161 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) | |
| 1162 | + | if templ_7745c5c3_Err != nil { | |
| 1163 | + | return templ_7745c5c3_Err | |
| 1164 | + | } | |
| 1165 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</a>") | |
| 1166 | + | if templ_7745c5c3_Err != nil { | |
| 1167 | + | return templ_7745c5c3_Err | |
| 1168 | + | } | |
| 1169 | + | } else { | |
| 1170 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<span>") | |
| 1171 | + | if templ_7745c5c3_Err != nil { | |
| 1172 | + | return templ_7745c5c3_Err | |
| 1173 | + | } | |
| 1174 | + | var templ_7745c5c3_Var65 string | |
| 1175 | + | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(a.Where) | |
| 1176 | + | if templ_7745c5c3_Err != nil { | |
| 1177 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 156, Col: 23} | |
| 1178 | + | } | |
| 1179 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 806 | 1180 | if templ_7745c5c3_Err != nil { | |
| 807 | 1181 | return templ_7745c5c3_Err | |
| 808 | 1182 | } | |
| 809 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></li>") | |
| 1183 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span>") | |
| 810 | 1184 | if templ_7745c5c3_Err != nil { | |
| 811 | 1185 | return templ_7745c5c3_Err | |
| 812 | 1186 | } | |
| 813 | 1187 | } | |
| 814 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</ol>") | |
| 1188 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>") | |
| 815 | 1189 | if templ_7745c5c3_Err != nil { | |
| 816 | 1190 | return templ_7745c5c3_Err | |
| 817 | 1191 | } | |
| 818 | 1192 | } | |
| 819 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>") | |
| 1193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div></li>") | |
| 1194 | + | if templ_7745c5c3_Err != nil { | |
| 1195 | + | return templ_7745c5c3_Err | |
| 1196 | + | } | |
| 1197 | + | } | |
| 1198 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</ul>") | |
| 1199 | + | if templ_7745c5c3_Err != nil { | |
| 1200 | + | return templ_7745c5c3_Err | |
| 1201 | + | } | |
| 1202 | + | if more { | |
| 1203 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<p class=\"mute\">more annotations exist than are shown.</p>") | |
| 1204 | + | if templ_7745c5c3_Err != nil { | |
| 1205 | + | return templ_7745c5c3_Err | |
| 1206 | + | } | |
| 1207 | + | } | |
| 1208 | + | return nil | |
| 1209 | + | }) | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | // JobAnnotations is the annotations of the job page, replaced by the live | |
| 1213 | + | // updates; the element exists even when there is none, so that the first one | |
| 1214 | + | // has something to replace. | |
| 1215 | + | func JobAnnotations(anns []AnnotationView, more bool) templ.Component { | |
| 1216 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1217 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1218 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1219 | + | return templ_7745c5c3_CtxErr | |
| 1220 | + | } | |
| 1221 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1222 | + | if !templ_7745c5c3_IsBuffer { | |
| 1223 | + | defer func() { | |
| 1224 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1225 | + | if templ_7745c5c3_Err == nil { | |
| 1226 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1227 | + | } | |
| 1228 | + | }() | |
| 1229 | + | } | |
| 1230 | + | ctx = templ.InitializeContext(ctx) | |
| 1231 | + | templ_7745c5c3_Var66 := templ.GetChildren(ctx) | |
| 1232 | + | if templ_7745c5c3_Var66 == nil { | |
| 1233 | + | templ_7745c5c3_Var66 = templ.NopComponent | |
| 1234 | + | } | |
| 1235 | + | ctx = templ.ClearChildren(ctx) | |
| 1236 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<section id=\"job-annotations\" class=\"annots\" aria-label=\"Annotations\">") | |
| 1237 | + | if templ_7745c5c3_Err != nil { | |
| 1238 | + | return templ_7745c5c3_Err | |
| 1239 | + | } | |
| 1240 | + | if len(anns) > 0 { | |
| 1241 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<h3>Annotations <span class=\"count\">") | |
| 1242 | + | if templ_7745c5c3_Err != nil { | |
| 1243 | + | return templ_7745c5c3_Err | |
| 1244 | + | } | |
| 1245 | + | var templ_7745c5c3_Var67 string | |
| 1246 | + | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(anns))) | |
| 1247 | + | if templ_7745c5c3_Err != nil { | |
| 1248 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 56} | |
| 1249 | + | } | |
| 1250 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) | |
| 1251 | + | if templ_7745c5c3_Err != nil { | |
| 1252 | + | return templ_7745c5c3_Err | |
| 1253 | + | } | |
| 1254 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span></h3>") | |
| 1255 | + | if templ_7745c5c3_Err != nil { | |
| 1256 | + | return templ_7745c5c3_Err | |
| 1257 | + | } | |
| 1258 | + | templ_7745c5c3_Err = AnnotationList(anns, more, true).Render(ctx, templ_7745c5c3_Buffer) | |
| 820 | 1259 | if templ_7745c5c3_Err != nil { | |
| 821 | 1260 | return templ_7745c5c3_Err | |
| 822 | 1261 | } | |
| 823 | 1262 | } | |
| 824 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>") | |
| 1263 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</section>") | |
| 825 | 1264 | if templ_7745c5c3_Err != nil { | |
| 826 | 1265 | return templ_7745c5c3_Err | |
| 827 | 1266 | } | |
| @@ -829,6 +1268,192 @@ func RunBody(p RunPage) templ.Component { | |||
| 829 | 1268 | }) | |
| 830 | 1269 | } | |
| 831 | 1270 | ||
| 1271 | + | // RunSummaries is the job summaries of a run, one block per step that wrote | |
| 1272 | + | // one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs | |
| 1273 | + | // filtered, images shown as their alt text. | |
| 1274 | + | func RunSummaries(sums []JobSummaries, more bool) templ.Component { | |
| 1275 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1276 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1277 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1278 | + | return templ_7745c5c3_CtxErr | |
| 1279 | + | } | |
| 1280 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1281 | + | if !templ_7745c5c3_IsBuffer { | |
| 1282 | + | defer func() { | |
| 1283 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1284 | + | if templ_7745c5c3_Err == nil { | |
| 1285 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1286 | + | } | |
| 1287 | + | }() | |
| 1288 | + | } | |
| 1289 | + | ctx = templ.InitializeContext(ctx) | |
| 1290 | + | templ_7745c5c3_Var68 := templ.GetChildren(ctx) | |
| 1291 | + | if templ_7745c5c3_Var68 == nil { | |
| 1292 | + | templ_7745c5c3_Var68 = templ.NopComponent | |
| 1293 | + | } | |
| 1294 | + | ctx = templ.ClearChildren(ctx) | |
| 1295 | + | for _, j := range sums { | |
| 1296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<section class=\"summaries\" aria-label=\"") | |
| 1297 | + | if templ_7745c5c3_Err != nil { | |
| 1298 | + | return templ_7745c5c3_Err | |
| 1299 | + | } | |
| 1300 | + | var templ_7745c5c3_Var69 string | |
| 1301 | + | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue("Summary of " + j.Job) | |
| 1302 | + | if templ_7745c5c3_Err != nil { | |
| 1303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 186, Col: 63} | |
| 1304 | + | } | |
| 1305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1306 | + | if templ_7745c5c3_Err != nil { | |
| 1307 | + | return templ_7745c5c3_Err | |
| 1308 | + | } | |
| 1309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"><h3>") | |
| 1310 | + | if templ_7745c5c3_Err != nil { | |
| 1311 | + | return templ_7745c5c3_Err | |
| 1312 | + | } | |
| 1313 | + | if j.JobHref != "" { | |
| 1314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<a href=\"") | |
| 1315 | + | if templ_7745c5c3_Err != nil { | |
| 1316 | + | return templ_7745c5c3_Err | |
| 1317 | + | } | |
| 1318 | + | var templ_7745c5c3_Var70 templ.SafeURL | |
| 1319 | + | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.JobHref)) | |
| 1320 | + | if templ_7745c5c3_Err != nil { | |
| 1321 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 39} | |
| 1322 | + | } | |
| 1323 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1324 | + | if templ_7745c5c3_Err != nil { | |
| 1325 | + | return templ_7745c5c3_Err | |
| 1326 | + | } | |
| 1327 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">") | |
| 1328 | + | if templ_7745c5c3_Err != nil { | |
| 1329 | + | return templ_7745c5c3_Err | |
| 1330 | + | } | |
| 1331 | + | var templ_7745c5c3_Var71 string | |
| 1332 | + | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job) | |
| 1333 | + | if templ_7745c5c3_Err != nil { | |
| 1334 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 49} | |
| 1335 | + | } | |
| 1336 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) | |
| 1337 | + | if templ_7745c5c3_Err != nil { | |
| 1338 | + | return templ_7745c5c3_Err | |
| 1339 | + | } | |
| 1340 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</a> ") | |
| 1341 | + | if templ_7745c5c3_Err != nil { | |
| 1342 | + | return templ_7745c5c3_Err | |
| 1343 | + | } | |
| 1344 | + | } else { | |
| 1345 | + | var templ_7745c5c3_Var72 string | |
| 1346 | + | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(j.Job) | |
| 1347 | + | if templ_7745c5c3_Err != nil { | |
| 1348 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 191, Col: 12} | |
| 1349 | + | } | |
| 1350 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) | |
| 1351 | + | if templ_7745c5c3_Err != nil { | |
| 1352 | + | return templ_7745c5c3_Err | |
| 1353 | + | } | |
| 1354 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " ") | |
| 1355 | + | if templ_7745c5c3_Err != nil { | |
| 1356 | + | return templ_7745c5c3_Err | |
| 1357 | + | } | |
| 1358 | + | } | |
| 1359 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<span class=\"mute\">summary</span></h3>") | |
| 1360 | + | if templ_7745c5c3_Err != nil { | |
| 1361 | + | return templ_7745c5c3_Err | |
| 1362 | + | } | |
| 1363 | + | for _, s := range j.Steps { | |
| 1364 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<article class=\"prose summary\" aria-label=\"") | |
| 1365 | + | if templ_7745c5c3_Err != nil { | |
| 1366 | + | return templ_7745c5c3_Err | |
| 1367 | + | } | |
| 1368 | + | var templ_7745c5c3_Var73 string | |
| 1369 | + | templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Step) | |
| 1370 | + | if templ_7745c5c3_Err != nil { | |
| 1371 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 196, Col: 54} | |
| 1372 | + | } | |
| 1373 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73) | |
| 1374 | + | if templ_7745c5c3_Err != nil { | |
| 1375 | + | return templ_7745c5c3_Err | |
| 1376 | + | } | |
| 1377 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" hx-ignore><h4 class=\"summary-step\">") | |
| 1378 | + | if templ_7745c5c3_Err != nil { | |
| 1379 | + | return templ_7745c5c3_Err | |
| 1380 | + | } | |
| 1381 | + | var templ_7745c5c3_Var74 string | |
| 1382 | + | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(s.Step) | |
| 1383 | + | if templ_7745c5c3_Err != nil { | |
| 1384 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 197, Col: 38} | |
| 1385 | + | } | |
| 1386 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) | |
| 1387 | + | if templ_7745c5c3_Err != nil { | |
| 1388 | + | return templ_7745c5c3_Err | |
| 1389 | + | } | |
| 1390 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</h4>") | |
| 1391 | + | if templ_7745c5c3_Err != nil { | |
| 1392 | + | return templ_7745c5c3_Err | |
| 1393 | + | } | |
| 1394 | + | if s.Note != "" { | |
| 1395 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<p class=\"dim\">") | |
| 1396 | + | if templ_7745c5c3_Err != nil { | |
| 1397 | + | return templ_7745c5c3_Err | |
| 1398 | + | } | |
| 1399 | + | var templ_7745c5c3_Var75 string | |
| 1400 | + | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note) | |
| 1401 | + | if templ_7745c5c3_Err != nil { | |
| 1402 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 199, Col: 29} | |
| 1403 | + | } | |
| 1404 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) | |
| 1405 | + | if templ_7745c5c3_Err != nil { | |
| 1406 | + | return templ_7745c5c3_Err | |
| 1407 | + | } | |
| 1408 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</p>") | |
| 1409 | + | if templ_7745c5c3_Err != nil { | |
| 1410 | + | return templ_7745c5c3_Err | |
| 1411 | + | } | |
| 1412 | + | } else { | |
| 1413 | + | templ_7745c5c3_Err = templ.Raw(s.HTML).Render(ctx, templ_7745c5c3_Buffer) | |
| 1414 | + | if templ_7745c5c3_Err != nil { | |
| 1415 | + | return templ_7745c5c3_Err | |
| 1416 | + | } | |
| 1417 | + | } | |
| 1418 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</article>") | |
| 1419 | + | if templ_7745c5c3_Err != nil { | |
| 1420 | + | return templ_7745c5c3_Err | |
| 1421 | + | } | |
| 1422 | + | } | |
| 1423 | + | if j.More { | |
| 1424 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"mute\">the job has more summaries than the first 20, which are shown.</p>") | |
| 1425 | + | if templ_7745c5c3_Err != nil { | |
| 1426 | + | return templ_7745c5c3_Err | |
| 1427 | + | } | |
| 1428 | + | } | |
| 1429 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</section>") | |
| 1430 | + | if templ_7745c5c3_Err != nil { | |
| 1431 | + | return templ_7745c5c3_Err | |
| 1432 | + | } | |
| 1433 | + | } | |
| 1434 | + | if more { | |
| 1435 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<p class=\"mute\">more summaries are not shown: the summaries of the page are over ") | |
| 1436 | + | if templ_7745c5c3_Err != nil { | |
| 1437 | + | return templ_7745c5c3_Err | |
| 1438 | + | } | |
| 1439 | + | var templ_7745c5c3_Var76 string | |
| 1440 | + | templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(humanSize(maxSummaryPageBytes)) | |
| 1441 | + | if templ_7745c5c3_Err != nil { | |
| 1442 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 115} | |
| 1443 | + | } | |
| 1444 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) | |
| 1445 | + | if templ_7745c5c3_Err != nil { | |
| 1446 | + | return templ_7745c5c3_Err | |
| 1447 | + | } | |
| 1448 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, ".</p>") | |
| 1449 | + | if templ_7745c5c3_Err != nil { | |
| 1450 | + | return templ_7745c5c3_Err | |
| 1451 | + | } | |
| 1452 | + | } | |
| 1453 | + | return nil | |
| 1454 | + | }) | |
| 1455 | + | } | |
| 1456 | + | ||
| 832 | 1457 | func runRail(p RunPage) templ.Component { | |
| 833 | 1458 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 834 | 1459 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| @@ -845,256 +1470,256 @@ func runRail(p RunPage) templ.Component { | |||
| 845 | 1470 | }() | |
| 846 | 1471 | } | |
| 847 | 1472 | ctx = templ.InitializeContext(ctx) | |
| 848 | - | templ_7745c5c3_Var45 := templ.GetChildren(ctx) | |
| 849 | - | if templ_7745c5c3_Var45 == nil { | |
| 850 | - | templ_7745c5c3_Var45 = templ.NopComponent | |
| 1473 | + | templ_7745c5c3_Var77 := templ.GetChildren(ctx) | |
| 1474 | + | if templ_7745c5c3_Var77 == nil { | |
| 1475 | + | templ_7745c5c3_Var77 = templ.NopComponent | |
| 851 | 1476 | } | |
| 852 | 1477 | ctx = templ.ClearChildren(ctx) | |
| 853 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>") | |
| 1478 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>") | |
| 854 | 1479 | if templ_7745c5c3_Err != nil { | |
| 855 | 1480 | return templ_7745c5c3_Err | |
| 856 | 1481 | } | |
| 857 | - | var templ_7745c5c3_Var46 string | |
| 858 | - | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.State) | |
| 1482 | + | var templ_7745c5c3_Var78 string | |
| 1483 | + | templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(p.State) | |
| 859 | 1484 | if templ_7745c5c3_Err != nil { | |
| 860 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 16} | |
| 1485 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 220, Col: 16} | |
| 861 | 1486 | } | |
| 862 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 1487 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) | |
| 863 | 1488 | if templ_7745c5c3_Err != nil { | |
| 864 | 1489 | return templ_7745c5c3_Err | |
| 865 | 1490 | } | |
| 866 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dd><dt>commit</dt><dd><a href=\"") | |
| 1491 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</dd><dt>commit</dt><dd><a href=\"") | |
| 867 | 1492 | if templ_7745c5c3_Err != nil { | |
| 868 | 1493 | return templ_7745c5c3_Err | |
| 869 | 1494 | } | |
| 870 | - | var templ_7745c5c3_Var47 templ.SafeURL | |
| 871 | - | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref)) | |
| 1495 | + | var templ_7745c5c3_Var79 templ.SafeURL | |
| 1496 | + | templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref)) | |
| 872 | 1497 | if templ_7745c5c3_Err != nil { | |
| 873 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 39} | |
| 1498 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 222, Col: 39} | |
| 874 | 1499 | } | |
| 875 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 1500 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) | |
| 876 | 1501 | if templ_7745c5c3_Err != nil { | |
| 877 | 1502 | return templ_7745c5c3_Err | |
| 878 | 1503 | } | |
| 879 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">") | |
| 1504 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">") | |
| 880 | 1505 | if templ_7745c5c3_Err != nil { | |
| 881 | 1506 | return templ_7745c5c3_Err | |
| 882 | 1507 | } | |
| 883 | - | var templ_7745c5c3_Var48 string | |
| 884 | - | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short) | |
| 1508 | + | var templ_7745c5c3_Var80 string | |
| 1509 | + | templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short) | |
| 885 | 1510 | if templ_7745c5c3_Err != nil { | |
| 886 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 51} | |
| 1511 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 222, Col: 51} | |
| 887 | 1512 | } | |
| 888 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 1513 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) | |
| 889 | 1514 | if templ_7745c5c3_Err != nil { | |
| 890 | 1515 | return templ_7745c5c3_Err | |
| 891 | 1516 | } | |
| 892 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a></dd><dt>ref</dt><dd><a href=\"") | |
| 1517 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</a></dd><dt>ref</dt><dd><a href=\"") | |
| 893 | 1518 | if templ_7745c5c3_Err != nil { | |
| 894 | 1519 | return templ_7745c5c3_Err | |
| 895 | 1520 | } | |
| 896 | - | var templ_7745c5c3_Var49 templ.SafeURL | |
| 897 | - | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref)) | |
| 1521 | + | var templ_7745c5c3_Var81 templ.SafeURL | |
| 1522 | + | templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref)) | |
| 898 | 1523 | if templ_7745c5c3_Err != nil { | |
| 899 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 41} | |
| 1524 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 224, Col: 41} | |
| 900 | 1525 | } | |
| 901 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1526 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) | |
| 902 | 1527 | if templ_7745c5c3_Err != nil { | |
| 903 | 1528 | return templ_7745c5c3_Err | |
| 904 | 1529 | } | |
| 905 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">") | |
| 1530 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">") | |
| 906 | 1531 | if templ_7745c5c3_Err != nil { | |
| 907 | 1532 | return templ_7745c5c3_Err | |
| 908 | 1533 | } | |
| 909 | - | var templ_7745c5c3_Var50 string | |
| 910 | - | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref) | |
| 1534 | + | var templ_7745c5c3_Var82 string | |
| 1535 | + | templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref) | |
| 911 | 1536 | if templ_7745c5c3_Err != nil { | |
| 912 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 51} | |
| 1537 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 224, Col: 51} | |
| 913 | 1538 | } | |
| 914 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1539 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) | |
| 915 | 1540 | if templ_7745c5c3_Err != nil { | |
| 916 | 1541 | return templ_7745c5c3_Err | |
| 917 | 1542 | } | |
| 918 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a></dd><dt>actor</dt><dd>") | |
| 1543 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</a></dd><dt>actor</dt><dd>") | |
| 919 | 1544 | if templ_7745c5c3_Err != nil { | |
| 920 | 1545 | return templ_7745c5c3_Err | |
| 921 | 1546 | } | |
| 922 | - | var templ_7745c5c3_Var51 string | |
| 923 | - | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor) | |
| 1547 | + | var templ_7745c5c3_Var83 string | |
| 1548 | + | templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor) | |
| 924 | 1549 | if templ_7745c5c3_Err != nil { | |
| 925 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 121, Col: 16} | |
| 1550 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 226, Col: 16} | |
| 926 | 1551 | } | |
| 927 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 1552 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) | |
| 928 | 1553 | if templ_7745c5c3_Err != nil { | |
| 929 | 1554 | return templ_7745c5c3_Err | |
| 930 | 1555 | } | |
| 931 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</dd><dt>attempt</dt><dd>") | |
| 1556 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</dd><dt>attempt</dt><dd>") | |
| 932 | 1557 | if templ_7745c5c3_Err != nil { | |
| 933 | 1558 | return templ_7745c5c3_Err | |
| 934 | 1559 | } | |
| 935 | - | var templ_7745c5c3_Var52 string | |
| 936 | - | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt))) | |
| 1560 | + | var templ_7745c5c3_Var84 string | |
| 1561 | + | templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt))) | |
| 937 | 1562 | if templ_7745c5c3_Err != nil { | |
| 938 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 29} | |
| 1563 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 228, Col: 29} | |
| 939 | 1564 | } | |
| 940 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 1565 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) | |
| 941 | 1566 | if templ_7745c5c3_Err != nil { | |
| 942 | 1567 | return templ_7745c5c3_Err | |
| 943 | 1568 | } | |
| 944 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " of ") | |
| 1569 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, " of ") | |
| 945 | 1570 | if templ_7745c5c3_Err != nil { | |
| 946 | 1571 | return templ_7745c5c3_Err | |
| 947 | 1572 | } | |
| 948 | - | var templ_7745c5c3_Var53 string | |
| 949 | - | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest))) | |
| 1573 | + | var templ_7745c5c3_Var85 string | |
| 1574 | + | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest))) | |
| 950 | 1575 | if templ_7745c5c3_Err != nil { | |
| 951 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 56} | |
| 1576 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 228, Col: 56} | |
| 952 | 1577 | } | |
| 953 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1578 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) | |
| 954 | 1579 | if templ_7745c5c3_Err != nil { | |
| 955 | 1580 | return templ_7745c5c3_Err | |
| 956 | 1581 | } | |
| 957 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</dd></dl>") | |
| 1582 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</dd></dl>") | |
| 958 | 1583 | if templ_7745c5c3_Err != nil { | |
| 959 | 1584 | return templ_7745c5c3_Err | |
| 960 | 1585 | } | |
| 961 | 1586 | if len(p.Attempts) > 0 { | |
| 962 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<ul class=\"attempts\">") | |
| 1587 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<ul class=\"attempts\">") | |
| 963 | 1588 | if templ_7745c5c3_Err != nil { | |
| 964 | 1589 | return templ_7745c5c3_Err | |
| 965 | 1590 | } | |
| 966 | 1591 | for _, a := range p.Attempts { | |
| 967 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li>") | |
| 1592 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<li>") | |
| 968 | 1593 | if templ_7745c5c3_Err != nil { | |
| 969 | 1594 | return templ_7745c5c3_Err | |
| 970 | 1595 | } | |
| 971 | 1596 | if a.Current { | |
| 972 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span aria-current=\"page\">") | |
| 1597 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<span aria-current=\"page\">") | |
| 973 | 1598 | if templ_7745c5c3_Err != nil { | |
| 974 | 1599 | return templ_7745c5c3_Err | |
| 975 | 1600 | } | |
| 976 | - | var templ_7745c5c3_Var54 string | |
| 977 | - | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 1601 | + | var templ_7745c5c3_Var86 string | |
| 1602 | + | templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 978 | 1603 | if templ_7745c5c3_Err != nil { | |
| 979 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 130, Col: 42} | |
| 1604 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 235, Col: 42} | |
| 980 | 1605 | } | |
| 981 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 1606 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) | |
| 982 | 1607 | if templ_7745c5c3_Err != nil { | |
| 983 | 1608 | return templ_7745c5c3_Err | |
| 984 | 1609 | } | |
| 985 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span>") | |
| 1610 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</span>") | |
| 986 | 1611 | if templ_7745c5c3_Err != nil { | |
| 987 | 1612 | return templ_7745c5c3_Err | |
| 988 | 1613 | } | |
| 989 | 1614 | } else { | |
| 990 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<a href=\"") | |
| 1615 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<a href=\"") | |
| 991 | 1616 | if templ_7745c5c3_Err != nil { | |
| 992 | 1617 | return templ_7745c5c3_Err | |
| 993 | 1618 | } | |
| 994 | - | var templ_7745c5c3_Var55 templ.SafeURL | |
| 995 | - | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href)) | |
| 1619 | + | var templ_7745c5c3_Var87 templ.SafeURL | |
| 1620 | + | templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href)) | |
| 996 | 1621 | if templ_7745c5c3_Err != nil { | |
| 997 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 38} | |
| 1622 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 237, Col: 38} | |
| 998 | 1623 | } | |
| 999 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1624 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) | |
| 1000 | 1625 | if templ_7745c5c3_Err != nil { | |
| 1001 | 1626 | return templ_7745c5c3_Err | |
| 1002 | 1627 | } | |
| 1003 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\">") | |
| 1628 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">") | |
| 1004 | 1629 | if templ_7745c5c3_Err != nil { | |
| 1005 | 1630 | return templ_7745c5c3_Err | |
| 1006 | 1631 | } | |
| 1007 | - | var templ_7745c5c3_Var56 string | |
| 1008 | - | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 1632 | + | var templ_7745c5c3_Var88 string | |
| 1633 | + | templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label) | |
| 1009 | 1634 | if templ_7745c5c3_Err != nil { | |
| 1010 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 50} | |
| 1635 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 237, Col: 50} | |
| 1011 | 1636 | } | |
| 1012 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1637 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) | |
| 1013 | 1638 | if templ_7745c5c3_Err != nil { | |
| 1014 | 1639 | return templ_7745c5c3_Err | |
| 1015 | 1640 | } | |
| 1016 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</a>") | |
| 1641 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</a>") | |
| 1017 | 1642 | if templ_7745c5c3_Err != nil { | |
| 1018 | 1643 | return templ_7745c5c3_Err | |
| 1019 | 1644 | } | |
| 1020 | 1645 | } | |
| 1021 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</li>") | |
| 1646 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</li>") | |
| 1022 | 1647 | if templ_7745c5c3_Err != nil { | |
| 1023 | 1648 | return templ_7745c5c3_Err | |
| 1024 | 1649 | } | |
| 1025 | 1650 | } | |
| 1026 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</ul>") | |
| 1651 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</ul>") | |
| 1027 | 1652 | if templ_7745c5c3_Err != nil { | |
| 1028 | 1653 | return templ_7745c5c3_Err | |
| 1029 | 1654 | } | |
| 1030 | 1655 | } | |
| 1031 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</section>") | |
| 1656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "</section>") | |
| 1032 | 1657 | if templ_7745c5c3_Err != nil { | |
| 1033 | 1658 | return templ_7745c5c3_Err | |
| 1034 | 1659 | } | |
| 1035 | 1660 | if p.CancelAction != "" || p.RerunAction != "" { | |
| 1036 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">") | |
| 1661 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">") | |
| 1037 | 1662 | if templ_7745c5c3_Err != nil { | |
| 1038 | 1663 | return templ_7745c5c3_Err | |
| 1039 | 1664 | } | |
| 1040 | 1665 | if p.CancelAction != "" { | |
| 1041 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form method=\"post\" action=\"") | |
| 1666 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<form method=\"post\" action=\"") | |
| 1042 | 1667 | if templ_7745c5c3_Err != nil { | |
| 1043 | 1668 | return templ_7745c5c3_Err | |
| 1044 | 1669 | } | |
| 1045 | - | var templ_7745c5c3_Var57 templ.SafeURL | |
| 1046 | - | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction)) | |
| 1670 | + | var templ_7745c5c3_Var89 templ.SafeURL | |
| 1671 | + | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction)) | |
| 1047 | 1672 | if templ_7745c5c3_Err != nil { | |
| 1048 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 144, Col: 63} | |
| 1673 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 249, Col: 63} | |
| 1049 | 1674 | } | |
| 1050 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1675 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) | |
| 1051 | 1676 | if templ_7745c5c3_Err != nil { | |
| 1052 | 1677 | return templ_7745c5c3_Err | |
| 1053 | 1678 | } | |
| 1054 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>") | |
| 1679 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>") | |
| 1055 | 1680 | if templ_7745c5c3_Err != nil { | |
| 1056 | 1681 | return templ_7745c5c3_Err | |
| 1057 | 1682 | } | |
| 1058 | 1683 | } | |
| 1059 | 1684 | if p.RerunAction != "" { | |
| 1060 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<form method=\"post\" action=\"") | |
| 1685 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<form method=\"post\" action=\"") | |
| 1061 | 1686 | if templ_7745c5c3_Err != nil { | |
| 1062 | 1687 | return templ_7745c5c3_Err | |
| 1063 | 1688 | } | |
| 1064 | - | var templ_7745c5c3_Var58 templ.SafeURL | |
| 1065 | - | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction)) | |
| 1689 | + | var templ_7745c5c3_Var90 templ.SafeURL | |
| 1690 | + | templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction)) | |
| 1066 | 1691 | if templ_7745c5c3_Err != nil { | |
| 1067 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 147, Col: 62} | |
| 1692 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 252, Col: 62} | |
| 1068 | 1693 | } | |
| 1069 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1694 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) | |
| 1070 | 1695 | if templ_7745c5c3_Err != nil { | |
| 1071 | 1696 | return templ_7745c5c3_Err | |
| 1072 | 1697 | } | |
| 1073 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>") | |
| 1698 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>") | |
| 1074 | 1699 | if templ_7745c5c3_Err != nil { | |
| 1075 | 1700 | return templ_7745c5c3_Err | |
| 1076 | 1701 | } | |
| 1077 | 1702 | } | |
| 1078 | 1703 | if p.CanRerunFailed { | |
| 1079 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<form method=\"post\" action=\"") | |
| 1704 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<form method=\"post\" action=\"") | |
| 1080 | 1705 | if templ_7745c5c3_Err != nil { | |
| 1081 | 1706 | return templ_7745c5c3_Err | |
| 1082 | 1707 | } | |
| 1083 | - | var templ_7745c5c3_Var59 templ.SafeURL | |
| 1084 | - | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction)) | |
| 1708 | + | var templ_7745c5c3_Var91 templ.SafeURL | |
| 1709 | + | templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction)) | |
| 1085 | 1710 | if templ_7745c5c3_Err != nil { | |
| 1086 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 150, Col: 68} | |
| 1711 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 255, Col: 68} | |
| 1087 | 1712 | } | |
| 1088 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1713 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) | |
| 1089 | 1714 | if templ_7745c5c3_Err != nil { | |
| 1090 | 1715 | return templ_7745c5c3_Err | |
| 1091 | 1716 | } | |
| 1092 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>") | |
| 1717 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>") | |
| 1093 | 1718 | if templ_7745c5c3_Err != nil { | |
| 1094 | 1719 | return templ_7745c5c3_Err | |
| 1095 | 1720 | } | |
| 1096 | 1721 | } | |
| 1097 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></section>") | |
| 1722 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "</div></section>") | |
| 1098 | 1723 | if templ_7745c5c3_Err != nil { | |
| 1099 | 1724 | return templ_7745c5c3_Err | |
| 1100 | 1725 | } | |
| @@ -1121,25 +1746,25 @@ func JobHead(j JobRow, total int64) templ.Component { | |||
| 1121 | 1746 | }() | |
| 1122 | 1747 | } | |
| 1123 | 1748 | ctx = templ.InitializeContext(ctx) | |
| 1124 | - | templ_7745c5c3_Var60 := templ.GetChildren(ctx) | |
| 1125 | - | if templ_7745c5c3_Var60 == nil { | |
| 1126 | - | templ_7745c5c3_Var60 = templ.NopComponent | |
| 1749 | + | templ_7745c5c3_Var92 := templ.GetChildren(ctx) | |
| 1750 | + | if templ_7745c5c3_Var92 == nil { | |
| 1751 | + | templ_7745c5c3_Var92 = templ.NopComponent | |
| 1127 | 1752 | } | |
| 1128 | 1753 | ctx = templ.ClearChildren(ctx) | |
| 1129 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div id=\"job-head\" data-total=\"") | |
| 1754 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "<div id=\"job-head\" data-total=\"") | |
| 1130 | 1755 | if templ_7745c5c3_Err != nil { | |
| 1131 | 1756 | return templ_7745c5c3_Err | |
| 1132 | 1757 | } | |
| 1133 | - | var templ_7745c5c3_Var61 string | |
| 1134 | - | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total)) | |
| 1758 | + | var templ_7745c5c3_Var93 string | |
| 1759 | + | templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total)) | |
| 1135 | 1760 | if templ_7745c5c3_Err != nil { | |
| 1136 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 160, Col: 46} | |
| 1761 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 265, Col: 46} | |
| 1137 | 1762 | } | |
| 1138 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61) | |
| 1763 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93) | |
| 1139 | 1764 | if templ_7745c5c3_Err != nil { | |
| 1140 | 1765 | return templ_7745c5c3_Err | |
| 1141 | 1766 | } | |
| 1142 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"><h2 class=\"run-h\">") | |
| 1767 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "\"><h2 class=\"run-h\">") | |
| 1143 | 1768 | if templ_7745c5c3_Err != nil { | |
| 1144 | 1769 | return templ_7745c5c3_Err | |
| 1145 | 1770 | } | |
| @@ -1147,74 +1772,74 @@ func JobHead(j JobRow, total int64) templ.Component { | |||
| 1147 | 1772 | if templ_7745c5c3_Err != nil { | |
| 1148 | 1773 | return templ_7745c5c3_Err | |
| 1149 | 1774 | } | |
| 1150 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<span>") | |
| 1775 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<span>") | |
| 1151 | 1776 | if templ_7745c5c3_Err != nil { | |
| 1152 | 1777 | return templ_7745c5c3_Err | |
| 1153 | 1778 | } | |
| 1154 | - | var templ_7745c5c3_Var62 string | |
| 1155 | - | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 1779 | + | var templ_7745c5c3_Var94 string | |
| 1780 | + | templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name) | |
| 1156 | 1781 | if templ_7745c5c3_Err != nil { | |
| 1157 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 61} | |
| 1782 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 61} | |
| 1158 | 1783 | } | |
| 1159 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1784 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) | |
| 1160 | 1785 | if templ_7745c5c3_Err != nil { | |
| 1161 | 1786 | return templ_7745c5c3_Err | |
| 1162 | 1787 | } | |
| 1163 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> <span class=\"dim\">") | |
| 1788 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</span> <span class=\"dim\">") | |
| 1164 | 1789 | if templ_7745c5c3_Err != nil { | |
| 1165 | 1790 | return templ_7745c5c3_Err | |
| 1166 | 1791 | } | |
| 1167 | - | var templ_7745c5c3_Var63 string | |
| 1168 | - | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 1792 | + | var templ_7745c5c3_Var95 string | |
| 1793 | + | templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(j.State) | |
| 1169 | 1794 | if templ_7745c5c3_Err != nil { | |
| 1170 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 98} | |
| 1795 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 98} | |
| 1171 | 1796 | } | |
| 1172 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1797 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) | |
| 1173 | 1798 | if templ_7745c5c3_Err != nil { | |
| 1174 | 1799 | return templ_7745c5c3_Err | |
| 1175 | 1800 | } | |
| 1176 | - | var templ_7745c5c3_Var64 string | |
| 1177 | - | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1801 | + | var templ_7745c5c3_Var96 string | |
| 1802 | + | templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1178 | 1803 | if templ_7745c5c3_Err != nil { | |
| 1179 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 105} | |
| 1804 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 105} | |
| 1180 | 1805 | } | |
| 1181 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) | |
| 1806 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) | |
| 1182 | 1807 | if templ_7745c5c3_Err != nil { | |
| 1183 | 1808 | return templ_7745c5c3_Err | |
| 1184 | 1809 | } | |
| 1185 | - | var templ_7745c5c3_Var65 string | |
| 1186 | - | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 1810 | + | var templ_7745c5c3_Var97 string | |
| 1811 | + | templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration) | |
| 1187 | 1812 | if templ_7745c5c3_Err != nil { | |
| 1188 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 119} | |
| 1813 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 119} | |
| 1189 | 1814 | } | |
| 1190 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1815 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) | |
| 1191 | 1816 | if templ_7745c5c3_Err != nil { | |
| 1192 | 1817 | return templ_7745c5c3_Err | |
| 1193 | 1818 | } | |
| 1194 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span></h2>") | |
| 1819 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</span></h2>") | |
| 1195 | 1820 | if templ_7745c5c3_Err != nil { | |
| 1196 | 1821 | return templ_7745c5c3_Err | |
| 1197 | 1822 | } | |
| 1198 | 1823 | if j.Error != "" { | |
| 1199 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"job-err\">") | |
| 1824 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<p class=\"job-err\">") | |
| 1200 | 1825 | if templ_7745c5c3_Err != nil { | |
| 1201 | 1826 | return templ_7745c5c3_Err | |
| 1202 | 1827 | } | |
| 1203 | - | var templ_7745c5c3_Var66 string | |
| 1204 | - | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 1828 | + | var templ_7745c5c3_Var98 string | |
| 1829 | + | templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error) | |
| 1205 | 1830 | if templ_7745c5c3_Err != nil { | |
| 1206 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 163, Col: 31} | |
| 1831 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 268, Col: 31} | |
| 1207 | 1832 | } | |
| 1208 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) | |
| 1833 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) | |
| 1209 | 1834 | if templ_7745c5c3_Err != nil { | |
| 1210 | 1835 | return templ_7745c5c3_Err | |
| 1211 | 1836 | } | |
| 1212 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>") | |
| 1837 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</p>") | |
| 1213 | 1838 | if templ_7745c5c3_Err != nil { | |
| 1214 | 1839 | return templ_7745c5c3_Err | |
| 1215 | 1840 | } | |
| 1216 | 1841 | } | |
| 1217 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div>") | |
| 1842 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</div>") | |
| 1218 | 1843 | if templ_7745c5c3_Err != nil { | |
| 1219 | 1844 | return templ_7745c5c3_Err | |
| 1220 | 1845 | } | |
| @@ -1239,17 +1864,17 @@ func JobSteps(steps []StepView) templ.Component { | |||
| 1239 | 1864 | }() | |
| 1240 | 1865 | } | |
| 1241 | 1866 | ctx = templ.InitializeContext(ctx) | |
| 1242 | - | templ_7745c5c3_Var67 := templ.GetChildren(ctx) | |
| 1243 | - | if templ_7745c5c3_Var67 == nil { | |
| 1244 | - | templ_7745c5c3_Var67 = templ.NopComponent | |
| 1867 | + | templ_7745c5c3_Var99 := templ.GetChildren(ctx) | |
| 1868 | + | if templ_7745c5c3_Var99 == nil { | |
| 1869 | + | templ_7745c5c3_Var99 = templ.NopComponent | |
| 1245 | 1870 | } | |
| 1246 | 1871 | ctx = templ.ClearChildren(ctx) | |
| 1247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<ol class=\"steps\" id=\"job-steps\">") | |
| 1872 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "<ol class=\"steps\" id=\"job-steps\">") | |
| 1248 | 1873 | if templ_7745c5c3_Err != nil { | |
| 1249 | 1874 | return templ_7745c5c3_Err | |
| 1250 | 1875 | } | |
| 1251 | 1876 | for _, s := range steps { | |
| 1252 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<li>") | |
| 1877 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<li>") | |
| 1253 | 1878 | if templ_7745c5c3_Err != nil { | |
| 1254 | 1879 | return templ_7745c5c3_Err | |
| 1255 | 1880 | } | |
| @@ -1258,74 +1883,74 @@ func JobSteps(steps []StepView) templ.Component { | |||
| 1258 | 1883 | return templ_7745c5c3_Err | |
| 1259 | 1884 | } | |
| 1260 | 1885 | if s.LogEnd > s.LogStart { | |
| 1261 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<a href=\"") | |
| 1886 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "<a href=\"") | |
| 1262 | 1887 | if templ_7745c5c3_Err != nil { | |
| 1263 | 1888 | return templ_7745c5c3_Err | |
| 1264 | 1889 | } | |
| 1265 | - | var templ_7745c5c3_Var68 templ.SafeURL | |
| 1266 | - | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart))) | |
| 1890 | + | var templ_7745c5c3_Var100 templ.SafeURL | |
| 1891 | + | templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart))) | |
| 1267 | 1892 | if templ_7745c5c3_Err != nil { | |
| 1268 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 55} | |
| 1893 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 280, Col: 55} | |
| 1269 | 1894 | } | |
| 1270 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) | |
| 1895 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) | |
| 1271 | 1896 | if templ_7745c5c3_Err != nil { | |
| 1272 | 1897 | return templ_7745c5c3_Err | |
| 1273 | 1898 | } | |
| 1274 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\">") | |
| 1899 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "\">") | |
| 1275 | 1900 | if templ_7745c5c3_Err != nil { | |
| 1276 | 1901 | return templ_7745c5c3_Err | |
| 1277 | 1902 | } | |
| 1278 | - | var templ_7745c5c3_Var69 string | |
| 1279 | - | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1903 | + | var templ_7745c5c3_Var101 string | |
| 1904 | + | templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1280 | 1905 | if templ_7745c5c3_Err != nil { | |
| 1281 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 66} | |
| 1906 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 280, Col: 66} | |
| 1282 | 1907 | } | |
| 1283 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) | |
| 1908 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) | |
| 1284 | 1909 | if templ_7745c5c3_Err != nil { | |
| 1285 | 1910 | return templ_7745c5c3_Err | |
| 1286 | 1911 | } | |
| 1287 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</a> ") | |
| 1912 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</a> ") | |
| 1288 | 1913 | if templ_7745c5c3_Err != nil { | |
| 1289 | 1914 | return templ_7745c5c3_Err | |
| 1290 | 1915 | } | |
| 1291 | 1916 | } else { | |
| 1292 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<span>") | |
| 1917 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<span>") | |
| 1293 | 1918 | if templ_7745c5c3_Err != nil { | |
| 1294 | 1919 | return templ_7745c5c3_Err | |
| 1295 | 1920 | } | |
| 1296 | - | var templ_7745c5c3_Var70 string | |
| 1297 | - | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1921 | + | var templ_7745c5c3_Var102 string | |
| 1922 | + | templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name) | |
| 1298 | 1923 | if templ_7745c5c3_Err != nil { | |
| 1299 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 177, Col: 19} | |
| 1924 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 282, Col: 19} | |
| 1300 | 1925 | } | |
| 1301 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1926 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) | |
| 1302 | 1927 | if templ_7745c5c3_Err != nil { | |
| 1303 | 1928 | return templ_7745c5c3_Err | |
| 1304 | 1929 | } | |
| 1305 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> ") | |
| 1930 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</span> ") | |
| 1306 | 1931 | if templ_7745c5c3_Err != nil { | |
| 1307 | 1932 | return templ_7745c5c3_Err | |
| 1308 | 1933 | } | |
| 1309 | 1934 | } | |
| 1310 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<span class=\"dim\">") | |
| 1935 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<span class=\"dim\">") | |
| 1311 | 1936 | if templ_7745c5c3_Err != nil { | |
| 1312 | 1937 | return templ_7745c5c3_Err | |
| 1313 | 1938 | } | |
| 1314 | - | var templ_7745c5c3_Var71 string | |
| 1315 | - | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 1939 | + | var templ_7745c5c3_Var103 string | |
| 1940 | + | templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration) | |
| 1316 | 1941 | if templ_7745c5c3_Err != nil { | |
| 1317 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 179, Col: 34} | |
| 1942 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 284, Col: 34} | |
| 1318 | 1943 | } | |
| 1319 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) | |
| 1944 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) | |
| 1320 | 1945 | if templ_7745c5c3_Err != nil { | |
| 1321 | 1946 | return templ_7745c5c3_Err | |
| 1322 | 1947 | } | |
| 1323 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></li>") | |
| 1948 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</span></li>") | |
| 1324 | 1949 | if templ_7745c5c3_Err != nil { | |
| 1325 | 1950 | return templ_7745c5c3_Err | |
| 1326 | 1951 | } | |
| 1327 | 1952 | } | |
| 1328 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</ol>") | |
| 1953 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</ol>") | |
| 1329 | 1954 | if templ_7745c5c3_Err != nil { | |
| 1330 | 1955 | return templ_7745c5c3_Err | |
| 1331 | 1956 | } | |
| @@ -1349,12 +1974,12 @@ func Job(l Layout, p JobPage) templ.Component { | |||
| 1349 | 1974 | }() | |
| 1350 | 1975 | } | |
| 1351 | 1976 | ctx = templ.InitializeContext(ctx) | |
| 1352 | - | templ_7745c5c3_Var72 := templ.GetChildren(ctx) | |
| 1353 | - | if templ_7745c5c3_Var72 == nil { | |
| 1354 | - | templ_7745c5c3_Var72 = templ.NopComponent | |
| 1977 | + | templ_7745c5c3_Var104 := templ.GetChildren(ctx) | |
| 1978 | + | if templ_7745c5c3_Var104 == nil { | |
| 1979 | + | templ_7745c5c3_Var104 = templ.NopComponent | |
| 1355 | 1980 | } | |
| 1356 | 1981 | ctx = templ.ClearChildren(ctx) | |
| 1357 | - | templ_7745c5c3_Var73 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1982 | + | templ_7745c5c3_Var105 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1358 | 1983 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1359 | 1984 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1360 | 1985 | if !templ_7745c5c3_IsBuffer { | |
| @@ -1370,7 +1995,7 @@ func Job(l Layout, p JobPage) templ.Component { | |||
| 1370 | 1995 | if templ_7745c5c3_Err != nil { | |
| 1371 | 1996 | return templ_7745c5c3_Err | |
| 1372 | 1997 | } | |
| 1373 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ") | |
| 1998 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, " ") | |
| 1374 | 1999 | if templ_7745c5c3_Err != nil { | |
| 1375 | 2000 | return templ_7745c5c3_Err | |
| 1376 | 2001 | } | |
| @@ -1378,111 +2003,119 @@ func Job(l Layout, p JobPage) templ.Component { | |||
| 1378 | 2003 | if templ_7745c5c3_Err != nil { | |
| 1379 | 2004 | return templ_7745c5c3_Err | |
| 1380 | 2005 | } | |
| 1381 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " <div id=\"job-log\" class=\"joblog\" data-total=\"") | |
| 2006 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, " ") | |
| 1382 | 2007 | if templ_7745c5c3_Err != nil { | |
| 1383 | 2008 | return templ_7745c5c3_Err | |
| 1384 | 2009 | } | |
| 1385 | - | var templ_7745c5c3_Var74 string | |
| 1386 | - | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total)) | |
| 2010 | + | templ_7745c5c3_Err = JobAnnotations(p.Annotations, p.AnnotationsMore).Render(ctx, templ_7745c5c3_Buffer) | |
| 1387 | 2011 | if templ_7745c5c3_Err != nil { | |
| 1388 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 63} | |
| 2012 | + | return templ_7745c5c3_Err | |
| 1389 | 2013 | } | |
| 1390 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74) | |
| 2014 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, " <div id=\"job-log\" class=\"joblog\" data-total=\"") | |
| 1391 | 2015 | if templ_7745c5c3_Err != nil { | |
| 1392 | 2016 | return templ_7745c5c3_Err | |
| 1393 | 2017 | } | |
| 1394 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"") | |
| 2018 | + | var templ_7745c5c3_Var106 string | |
| 2019 | + | templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total)) | |
| 2020 | + | if templ_7745c5c3_Err != nil { | |
| 2021 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 295, Col: 63} | |
| 2022 | + | } | |
| 2023 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var106) | |
| 2024 | + | if templ_7745c5c3_Err != nil { | |
| 2025 | + | return templ_7745c5c3_Err | |
| 2026 | + | } | |
| 2027 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\"") | |
| 1395 | 2028 | if templ_7745c5c3_Err != nil { | |
| 1396 | 2029 | return templ_7745c5c3_Err | |
| 1397 | 2030 | } | |
| 1398 | 2031 | if p.Follow { | |
| 1399 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " data-follow") | |
| 2032 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, " data-follow") | |
| 1400 | 2033 | if templ_7745c5c3_Err != nil { | |
| 1401 | 2034 | return templ_7745c5c3_Err | |
| 1402 | 2035 | } | |
| 1403 | 2036 | } | |
| 1404 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " data-stream=\"") | |
| 2037 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, " data-stream=\"") | |
| 1405 | 2038 | if templ_7745c5c3_Err != nil { | |
| 1406 | 2039 | return templ_7745c5c3_Err | |
| 1407 | 2040 | } | |
| 1408 | - | var templ_7745c5c3_Var75 string | |
| 1409 | - | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref) | |
| 2041 | + | var templ_7745c5c3_Var107 string | |
| 2042 | + | templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref) | |
| 1410 | 2043 | if templ_7745c5c3_Err != nil { | |
| 1411 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 118} | |
| 2044 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 295, Col: 118} | |
| 1412 | 2045 | } | |
| 1413 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75) | |
| 2046 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107) | |
| 1414 | 2047 | if templ_7745c5c3_Err != nil { | |
| 1415 | 2048 | return templ_7745c5c3_Err | |
| 1416 | 2049 | } | |
| 1417 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" data-next=\"") | |
| 2050 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\" data-next=\"") | |
| 1418 | 2051 | if templ_7745c5c3_Err != nil { | |
| 1419 | 2052 | return templ_7745c5c3_Err | |
| 1420 | 2053 | } | |
| 1421 | - | var templ_7745c5c3_Var76 string | |
| 1422 | - | templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines)))) | |
| 2054 | + | var templ_7745c5c3_Var108 string | |
| 2055 | + | templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines)))) | |
| 1423 | 2056 | if templ_7745c5c3_Err != nil { | |
| 1424 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 169} | |
| 2057 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 295, Col: 169} | |
| 1425 | 2058 | } | |
| 1426 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76) | |
| 2059 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var108) | |
| 1427 | 2060 | if templ_7745c5c3_Err != nil { | |
| 1428 | 2061 | return templ_7745c5c3_Err | |
| 1429 | 2062 | } | |
| 1430 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\">") | |
| 2063 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "\">") | |
| 1431 | 2064 | if templ_7745c5c3_Err != nil { | |
| 1432 | 2065 | return templ_7745c5c3_Err | |
| 1433 | 2066 | } | |
| 1434 | 2067 | if p.Note != "" { | |
| 1435 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">") | |
| 2068 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<p class=\"dim\">") | |
| 1436 | 2069 | if templ_7745c5c3_Err != nil { | |
| 1437 | 2070 | return templ_7745c5c3_Err | |
| 1438 | 2071 | } | |
| 1439 | - | var templ_7745c5c3_Var77 string | |
| 1440 | - | templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 2072 | + | var templ_7745c5c3_Var109 string | |
| 2073 | + | templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 1441 | 2074 | if templ_7745c5c3_Err != nil { | |
| 1442 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 191, Col: 27} | |
| 2075 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 297, Col: 27} | |
| 1443 | 2076 | } | |
| 1444 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) | |
| 2077 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109)) | |
| 1445 | 2078 | if templ_7745c5c3_Err != nil { | |
| 1446 | 2079 | return templ_7745c5c3_Err | |
| 1447 | 2080 | } | |
| 1448 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>") | |
| 2081 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</p>") | |
| 1449 | 2082 | if templ_7745c5c3_Err != nil { | |
| 1450 | 2083 | return templ_7745c5c3_Err | |
| 1451 | 2084 | } | |
| 1452 | 2085 | } | |
| 1453 | 2086 | if p.Prev != "" { | |
| 1454 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"more\"><a href=\"") | |
| 2087 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<p class=\"more\"><a href=\"") | |
| 1455 | 2088 | if templ_7745c5c3_Err != nil { | |
| 1456 | 2089 | return templ_7745c5c3_Err | |
| 1457 | 2090 | } | |
| 1458 | - | var templ_7745c5c3_Var78 templ.SafeURL | |
| 1459 | - | templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev)) | |
| 2091 | + | var templ_7745c5c3_Var110 templ.SafeURL | |
| 2092 | + | templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev)) | |
| 1460 | 2093 | if templ_7745c5c3_Err != nil { | |
| 1461 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 194, Col: 51} | |
| 2094 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 300, Col: 51} | |
| 1462 | 2095 | } | |
| 1463 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) | |
| 2096 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110)) | |
| 1464 | 2097 | if templ_7745c5c3_Err != nil { | |
| 1465 | 2098 | return templ_7745c5c3_Err | |
| 1466 | 2099 | } | |
| 1467 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" rel=\"prev\">earlier lines</a></p>") | |
| 2100 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\" rel=\"prev\">earlier lines</a></p>") | |
| 1468 | 2101 | if templ_7745c5c3_Err != nil { | |
| 1469 | 2102 | return templ_7745c5c3_Err | |
| 1470 | 2103 | } | |
| 1471 | 2104 | } | |
| 1472 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<ol class=\"log\" start=\"") | |
| 2105 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<ol class=\"log\" start=\"") | |
| 1473 | 2106 | if templ_7745c5c3_Err != nil { | |
| 1474 | 2107 | return templ_7745c5c3_Err | |
| 1475 | 2108 | } | |
| 1476 | - | var templ_7745c5c3_Var79 string | |
| 1477 | - | templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1)) | |
| 2109 | + | var templ_7745c5c3_Var111 string | |
| 2110 | + | templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1)) | |
| 1478 | 2111 | if templ_7745c5c3_Err != nil { | |
| 1479 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 196, Col: 45} | |
| 2112 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 302, Col: 45} | |
| 1480 | 2113 | } | |
| 1481 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79) | |
| 2114 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var111) | |
| 1482 | 2115 | if templ_7745c5c3_Err != nil { | |
| 1483 | 2116 | return templ_7745c5c3_Err | |
| 1484 | 2117 | } | |
| 1485 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">") | |
| 2118 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "\">") | |
| 1486 | 2119 | if templ_7745c5c3_Err != nil { | |
| 1487 | 2120 | return templ_7745c5c3_Err | |
| 1488 | 2121 | } | |
| @@ -1492,36 +2125,36 @@ func Job(l Layout, p JobPage) templ.Component { | |||
| 1492 | 2125 | return templ_7745c5c3_Err | |
| 1493 | 2126 | } | |
| 1494 | 2127 | } | |
| 1495 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</ol>") | |
| 2128 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</ol>") | |
| 1496 | 2129 | if templ_7745c5c3_Err != nil { | |
| 1497 | 2130 | return templ_7745c5c3_Err | |
| 1498 | 2131 | } | |
| 1499 | 2132 | if p.Next != "" { | |
| 1500 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<p class=\"more\"><a href=\"") | |
| 2133 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<p class=\"more\"><a href=\"") | |
| 1501 | 2134 | if templ_7745c5c3_Err != nil { | |
| 1502 | 2135 | return templ_7745c5c3_Err | |
| 1503 | 2136 | } | |
| 1504 | - | var templ_7745c5c3_Var80 templ.SafeURL | |
| 1505 | - | templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next)) | |
| 2137 | + | var templ_7745c5c3_Var112 templ.SafeURL | |
| 2138 | + | templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next)) | |
| 1506 | 2139 | if templ_7745c5c3_Err != nil { | |
| 1507 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 202, Col: 51} | |
| 2140 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 308, Col: 51} | |
| 1508 | 2141 | } | |
| 1509 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) | |
| 2142 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112)) | |
| 1510 | 2143 | if templ_7745c5c3_Err != nil { | |
| 1511 | 2144 | return templ_7745c5c3_Err | |
| 1512 | 2145 | } | |
| 1513 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" rel=\"next\">more lines</a></p>") | |
| 2146 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\" rel=\"next\">more lines</a></p>") | |
| 1514 | 2147 | if templ_7745c5c3_Err != nil { | |
| 1515 | 2148 | return templ_7745c5c3_Err | |
| 1516 | 2149 | } | |
| 1517 | 2150 | } | |
| 1518 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</div>") | |
| 2151 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</div>") | |
| 1519 | 2152 | if templ_7745c5c3_Err != nil { | |
| 1520 | 2153 | return templ_7745c5c3_Err | |
| 1521 | 2154 | } | |
| 1522 | 2155 | return nil | |
| 1523 | 2156 | }) | |
| 1524 | - | templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var73), templ_7745c5c3_Buffer) | |
| 2157 | + | templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var105), templ_7745c5c3_Buffer) | |
| 1525 | 2158 | if templ_7745c5c3_Err != nil { | |
| 1526 | 2159 | return templ_7745c5c3_Err | |
| 1527 | 2160 | } | |
| @@ -1547,92 +2180,92 @@ func LogLine(ln LogLineView) templ.Component { | |||
| 1547 | 2180 | }() | |
| 1548 | 2181 | } | |
| 1549 | 2182 | ctx = templ.InitializeContext(ctx) | |
| 1550 | - | templ_7745c5c3_Var81 := templ.GetChildren(ctx) | |
| 1551 | - | if templ_7745c5c3_Var81 == nil { | |
| 1552 | - | templ_7745c5c3_Var81 = templ.NopComponent | |
| 2183 | + | templ_7745c5c3_Var113 := templ.GetChildren(ctx) | |
| 2184 | + | if templ_7745c5c3_Var113 == nil { | |
| 2185 | + | templ_7745c5c3_Var113 = templ.NopComponent | |
| 1553 | 2186 | } | |
| 1554 | 2187 | ctx = templ.ClearChildren(ctx) | |
| 1555 | - | var templ_7745c5c3_Var82 = []any{logClass(ln)} | |
| 1556 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...) | |
| 2188 | + | var templ_7745c5c3_Var114 = []any{logClass(ln)} | |
| 2189 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var114...) | |
| 1557 | 2190 | if templ_7745c5c3_Err != nil { | |
| 1558 | 2191 | return templ_7745c5c3_Err | |
| 1559 | 2192 | } | |
| 1560 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<li id=\"") | |
| 2193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<li id=\"") | |
| 1561 | 2194 | if templ_7745c5c3_Err != nil { | |
| 1562 | 2195 | return templ_7745c5c3_Err | |
| 1563 | 2196 | } | |
| 1564 | - | var templ_7745c5c3_Var83 string | |
| 1565 | - | templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N)) | |
| 2197 | + | var templ_7745c5c3_Var115 string | |
| 2198 | + | templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N)) | |
| 1566 | 2199 | if templ_7745c5c3_Err != nil { | |
| 1567 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 28} | |
| 2200 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 317, Col: 28} | |
| 1568 | 2201 | } | |
| 1569 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83) | |
| 2202 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var115) | |
| 1570 | 2203 | if templ_7745c5c3_Err != nil { | |
| 1571 | 2204 | return templ_7745c5c3_Err | |
| 1572 | 2205 | } | |
| 1573 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" class=\"") | |
| 2206 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "\" class=\"") | |
| 1574 | 2207 | if templ_7745c5c3_Err != nil { | |
| 1575 | 2208 | return templ_7745c5c3_Err | |
| 1576 | 2209 | } | |
| 1577 | - | var templ_7745c5c3_Var84 string | |
| 1578 | - | templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String()) | |
| 2210 | + | var templ_7745c5c3_Var116 string | |
| 2211 | + | templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var114).String()) | |
| 1579 | 2212 | if templ_7745c5c3_Err != nil { | |
| 1580 | 2213 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0} | |
| 1581 | 2214 | } | |
| 1582 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84) | |
| 2215 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var116) | |
| 1583 | 2216 | if templ_7745c5c3_Err != nil { | |
| 1584 | 2217 | return templ_7745c5c3_Err | |
| 1585 | 2218 | } | |
| 1586 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" data-step=\"") | |
| 2219 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\" data-step=\"") | |
| 1587 | 2220 | if templ_7745c5c3_Err != nil { | |
| 1588 | 2221 | return templ_7745c5c3_Err | |
| 1589 | 2222 | } | |
| 1590 | - | var templ_7745c5c3_Var85 string | |
| 1591 | - | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step))) | |
| 2223 | + | var templ_7745c5c3_Var117 string | |
| 2224 | + | templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step))) | |
| 1592 | 2225 | if templ_7745c5c3_Err != nil { | |
| 1593 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 84} | |
| 2226 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 317, Col: 84} | |
| 1594 | 2227 | } | |
| 1595 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85) | |
| 2228 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var117) | |
| 1596 | 2229 | if templ_7745c5c3_Err != nil { | |
| 1597 | 2230 | return templ_7745c5c3_Err | |
| 1598 | 2231 | } | |
| 1599 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"") | |
| 2232 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"") | |
| 1600 | 2233 | if templ_7745c5c3_Err != nil { | |
| 1601 | 2234 | return templ_7745c5c3_Err | |
| 1602 | 2235 | } | |
| 1603 | 2236 | if ln.Continued { | |
| 1604 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, " data-cont") | |
| 2237 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " data-cont") | |
| 1605 | 2238 | if templ_7745c5c3_Err != nil { | |
| 1606 | 2239 | return templ_7745c5c3_Err | |
| 1607 | 2240 | } | |
| 1608 | 2241 | } | |
| 1609 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "><time>") | |
| 2242 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "><time>") | |
| 1610 | 2243 | if templ_7745c5c3_Err != nil { | |
| 1611 | 2244 | return templ_7745c5c3_Err | |
| 1612 | 2245 | } | |
| 1613 | - | var templ_7745c5c3_Var86 string | |
| 1614 | - | templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time) | |
| 2246 | + | var templ_7745c5c3_Var118 string | |
| 2247 | + | templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time) | |
| 1615 | 2248 | if templ_7745c5c3_Err != nil { | |
| 1616 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 130} | |
| 2249 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 317, Col: 130} | |
| 1617 | 2250 | } | |
| 1618 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) | |
| 2251 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118)) | |
| 1619 | 2252 | if templ_7745c5c3_Err != nil { | |
| 1620 | 2253 | return templ_7745c5c3_Err | |
| 1621 | 2254 | } | |
| 1622 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</time><span class=\"t\">") | |
| 2255 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</time><span class=\"t\">") | |
| 1623 | 2256 | if templ_7745c5c3_Err != nil { | |
| 1624 | 2257 | return templ_7745c5c3_Err | |
| 1625 | 2258 | } | |
| 1626 | - | var templ_7745c5c3_Var87 string | |
| 1627 | - | templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text) | |
| 2259 | + | var templ_7745c5c3_Var119 string | |
| 2260 | + | templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text) | |
| 1628 | 2261 | if templ_7745c5c3_Err != nil { | |
| 1629 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 164} | |
| 2262 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 317, Col: 164} | |
| 1630 | 2263 | } | |
| 1631 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) | |
| 2264 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119)) | |
| 1632 | 2265 | if templ_7745c5c3_Err != nil { | |
| 1633 | 2266 | return templ_7745c5c3_Err | |
| 1634 | 2267 | } | |
| 1635 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span></li>") | |
| 2268 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></li>") | |
| 1636 | 2269 | if templ_7745c5c3_Err != nil { | |
| 1637 | 2270 | return templ_7745c5c3_Err | |
| 1638 | 2271 | } | |
| @@ -1656,12 +2289,12 @@ func jobRail(p JobPage) templ.Component { | |||
| 1656 | 2289 | }() | |
| 1657 | 2290 | } | |
| 1658 | 2291 | ctx = templ.InitializeContext(ctx) | |
| 1659 | - | templ_7745c5c3_Var88 := templ.GetChildren(ctx) | |
| 1660 | - | if templ_7745c5c3_Var88 == nil { | |
| 1661 | - | templ_7745c5c3_Var88 = templ.NopComponent | |
| 2292 | + | templ_7745c5c3_Var120 := templ.GetChildren(ctx) | |
| 2293 | + | if templ_7745c5c3_Var120 == nil { | |
| 2294 | + | templ_7745c5c3_Var120 = templ.NopComponent | |
| 1662 | 2295 | } | |
| 1663 | 2296 | ctx = templ.ClearChildren(ctx) | |
| 1664 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>") | |
| 2297 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>") | |
| 1665 | 2298 | if templ_7745c5c3_Err != nil { | |
| 1666 | 2299 | return templ_7745c5c3_Err | |
| 1667 | 2300 | } | |
| @@ -1669,101 +2302,101 @@ func jobRail(p JobPage) templ.Component { | |||
| 1669 | 2302 | if templ_7745c5c3_Err != nil { | |
| 1670 | 2303 | return templ_7745c5c3_Err | |
| 1671 | 2304 | } | |
| 1672 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"") | |
| 2305 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"") | |
| 1673 | 2306 | if templ_7745c5c3_Err != nil { | |
| 1674 | 2307 | return templ_7745c5c3_Err | |
| 1675 | 2308 | } | |
| 1676 | - | var templ_7745c5c3_Var89 templ.SafeURL | |
| 1677 | - | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref)) | |
| 2309 | + | var templ_7745c5c3_Var121 templ.SafeURL | |
| 2310 | + | templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref)) | |
| 1678 | 2311 | if templ_7745c5c3_Err != nil { | |
| 1679 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 41} | |
| 2312 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 329, Col: 41} | |
| 1680 | 2313 | } | |
| 1681 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) | |
| 2314 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121)) | |
| 1682 | 2315 | if templ_7745c5c3_Err != nil { | |
| 1683 | 2316 | return templ_7745c5c3_Err | |
| 1684 | 2317 | } | |
| 1685 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">") | |
| 2318 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "\">") | |
| 1686 | 2319 | if templ_7745c5c3_Err != nil { | |
| 1687 | 2320 | return templ_7745c5c3_Err | |
| 1688 | 2321 | } | |
| 1689 | - | var templ_7745c5c3_Var90 string | |
| 1690 | - | templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName) | |
| 2322 | + | var templ_7745c5c3_Var122 string | |
| 2323 | + | templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName) | |
| 1691 | 2324 | if templ_7745c5c3_Err != nil { | |
| 1692 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 55} | |
| 2325 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 329, Col: 55} | |
| 1693 | 2326 | } | |
| 1694 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) | |
| 2327 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122)) | |
| 1695 | 2328 | if templ_7745c5c3_Err != nil { | |
| 1696 | 2329 | return templ_7745c5c3_Err | |
| 1697 | 2330 | } | |
| 1698 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</a></dd>") | |
| 2331 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</a></dd>") | |
| 1699 | 2332 | if templ_7745c5c3_Err != nil { | |
| 1700 | 2333 | return templ_7745c5c3_Err | |
| 1701 | 2334 | } | |
| 1702 | 2335 | if p.Runner != "" { | |
| 1703 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>runner</dt><dd>") | |
| 2336 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<dt>runner</dt><dd>") | |
| 1704 | 2337 | if templ_7745c5c3_Err != nil { | |
| 1705 | 2338 | return templ_7745c5c3_Err | |
| 1706 | 2339 | } | |
| 1707 | - | var templ_7745c5c3_Var91 string | |
| 1708 | - | templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner) | |
| 2340 | + | var templ_7745c5c3_Var123 string | |
| 2341 | + | templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner) | |
| 1709 | 2342 | if templ_7745c5c3_Err != nil { | |
| 1710 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 226, Col: 18} | |
| 2343 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 332, Col: 18} | |
| 1711 | 2344 | } | |
| 1712 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) | |
| 2345 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123)) | |
| 1713 | 2346 | if templ_7745c5c3_Err != nil { | |
| 1714 | 2347 | return templ_7745c5c3_Err | |
| 1715 | 2348 | } | |
| 1716 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>") | |
| 2349 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</dd>") | |
| 1717 | 2350 | if templ_7745c5c3_Err != nil { | |
| 1718 | 2351 | return templ_7745c5c3_Err | |
| 1719 | 2352 | } | |
| 1720 | 2353 | } | |
| 1721 | 2354 | if p.Isolation != "" { | |
| 1722 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>isolation</dt><dd>") | |
| 2355 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<dt>isolation</dt><dd>") | |
| 1723 | 2356 | if templ_7745c5c3_Err != nil { | |
| 1724 | 2357 | return templ_7745c5c3_Err | |
| 1725 | 2358 | } | |
| 1726 | - | var templ_7745c5c3_Var92 string | |
| 1727 | - | templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation) | |
| 2359 | + | var templ_7745c5c3_Var124 string | |
| 2360 | + | templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation) | |
| 1728 | 2361 | if templ_7745c5c3_Err != nil { | |
| 1729 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 230, Col: 21} | |
| 2362 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 336, Col: 21} | |
| 1730 | 2363 | } | |
| 1731 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) | |
| 2364 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124)) | |
| 1732 | 2365 | if templ_7745c5c3_Err != nil { | |
| 1733 | 2366 | return templ_7745c5c3_Err | |
| 1734 | 2367 | } | |
| 1735 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd>") | |
| 2368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "</dd>") | |
| 1736 | 2369 | if templ_7745c5c3_Err != nil { | |
| 1737 | 2370 | return templ_7745c5c3_Err | |
| 1738 | 2371 | } | |
| 1739 | 2372 | } | |
| 1740 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<dt>lines</dt><dd>") | |
| 2373 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<dt>lines</dt><dd>") | |
| 1741 | 2374 | if templ_7745c5c3_Err != nil { | |
| 1742 | 2375 | return templ_7745c5c3_Err | |
| 1743 | 2376 | } | |
| 1744 | - | var templ_7745c5c3_Var93 string | |
| 1745 | - | templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total)) | |
| 2377 | + | var templ_7745c5c3_Var125 string | |
| 2378 | + | templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total)) | |
| 1746 | 2379 | if templ_7745c5c3_Err != nil { | |
| 1747 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 233, Col: 24} | |
| 2380 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 339, Col: 24} | |
| 1748 | 2381 | } | |
| 1749 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) | |
| 2382 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125)) | |
| 1750 | 2383 | if templ_7745c5c3_Err != nil { | |
| 1751 | 2384 | return templ_7745c5c3_Err | |
| 1752 | 2385 | } | |
| 1753 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</dd></dl><p><a href=\"") | |
| 2386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</dd></dl><p><a href=\"") | |
| 1754 | 2387 | if templ_7745c5c3_Err != nil { | |
| 1755 | 2388 | return templ_7745c5c3_Err | |
| 1756 | 2389 | } | |
| 1757 | - | var templ_7745c5c3_Var94 templ.SafeURL | |
| 1758 | - | templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref)) | |
| 2390 | + | var templ_7745c5c3_Var126 templ.SafeURL | |
| 2391 | + | templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref)) | |
| 1759 | 2392 | if templ_7745c5c3_Err != nil { | |
| 1760 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 235, Col: 39} | |
| 2393 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 341, Col: 39} | |
| 1761 | 2394 | } | |
| 1762 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) | |
| 2395 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126)) | |
| 1763 | 2396 | if templ_7745c5c3_Err != nil { | |
| 1764 | 2397 | return templ_7745c5c3_Err | |
| 1765 | 2398 | } | |
| 1766 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">raw log</a></p></section>") | |
| 2399 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "\">raw log</a></p></section>") | |
| 1767 | 2400 | if templ_7745c5c3_Err != nil { | |
| 1768 | 2401 | return templ_7745c5c3_Err | |
| 1769 | 2402 | } | |
| @@ -1789,39 +2422,39 @@ func CommitChecks(c ChecksSummary) templ.Component { | |||
| 1789 | 2422 | }() | |
| 1790 | 2423 | } | |
| 1791 | 2424 | ctx = templ.InitializeContext(ctx) | |
| 1792 | - | templ_7745c5c3_Var95 := templ.GetChildren(ctx) | |
| 1793 | - | if templ_7745c5c3_Var95 == nil { | |
| 1794 | - | templ_7745c5c3_Var95 = templ.NopComponent | |
| 2425 | + | templ_7745c5c3_Var127 := templ.GetChildren(ctx) | |
| 2426 | + | if templ_7745c5c3_Var127 == nil { | |
| 2427 | + | templ_7745c5c3_Var127 = templ.NopComponent | |
| 1795 | 2428 | } | |
| 1796 | 2429 | ctx = templ.ClearChildren(ctx) | |
| 1797 | 2430 | if c.Total > 0 { | |
| 1798 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<a class=\"ck-sum\" href=\"") | |
| 2431 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<a class=\"ck-sum\" href=\"") | |
| 1799 | 2432 | if templ_7745c5c3_Err != nil { | |
| 1800 | 2433 | return templ_7745c5c3_Err | |
| 1801 | 2434 | } | |
| 1802 | - | var templ_7745c5c3_Var96 templ.SafeURL | |
| 1803 | - | templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 2435 | + | var templ_7745c5c3_Var128 templ.SafeURL | |
| 2436 | + | templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 1804 | 2437 | if templ_7745c5c3_Err != nil { | |
| 1805 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 48} | |
| 2438 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 349, Col: 48} | |
| 1806 | 2439 | } | |
| 1807 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) | |
| 2440 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) | |
| 1808 | 2441 | if templ_7745c5c3_Err != nil { | |
| 1809 | 2442 | return templ_7745c5c3_Err | |
| 1810 | 2443 | } | |
| 1811 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" title=\"") | |
| 2444 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "\" title=\"") | |
| 1812 | 2445 | if templ_7745c5c3_Err != nil { | |
| 1813 | 2446 | return templ_7745c5c3_Err | |
| 1814 | 2447 | } | |
| 1815 | - | var templ_7745c5c3_Var97 string | |
| 1816 | - | templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title) | |
| 2448 | + | var templ_7745c5c3_Var129 string | |
| 2449 | + | templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title) | |
| 1817 | 2450 | if templ_7745c5c3_Err != nil { | |
| 1818 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 66} | |
| 2451 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 349, Col: 66} | |
| 1819 | 2452 | } | |
| 1820 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var97) | |
| 2453 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129) | |
| 1821 | 2454 | if templ_7745c5c3_Err != nil { | |
| 1822 | 2455 | return templ_7745c5c3_Err | |
| 1823 | 2456 | } | |
| 1824 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">") | |
| 2457 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "\">") | |
| 1825 | 2458 | if templ_7745c5c3_Err != nil { | |
| 1826 | 2459 | return templ_7745c5c3_Err | |
| 1827 | 2460 | } | |
| @@ -1829,20 +2462,20 @@ func CommitChecks(c ChecksSummary) templ.Component { | |||
| 1829 | 2462 | if templ_7745c5c3_Err != nil { | |
| 1830 | 2463 | return templ_7745c5c3_Err | |
| 1831 | 2464 | } | |
| 1832 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span class=\"mute\">") | |
| 2465 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<span class=\"mute\">") | |
| 1833 | 2466 | if templ_7745c5c3_Err != nil { | |
| 1834 | 2467 | return templ_7745c5c3_Err | |
| 1835 | 2468 | } | |
| 1836 | - | var templ_7745c5c3_Var98 string | |
| 1837 | - | templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 2469 | + | var templ_7745c5c3_Var130 string | |
| 2470 | + | templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 1838 | 2471 | if templ_7745c5c3_Err != nil { | |
| 1839 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 245, Col: 31} | |
| 2472 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 351, Col: 31} | |
| 1840 | 2473 | } | |
| 1841 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) | |
| 2474 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) | |
| 1842 | 2475 | if templ_7745c5c3_Err != nil { | |
| 1843 | 2476 | return templ_7745c5c3_Err | |
| 1844 | 2477 | } | |
| 1845 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></a>") | |
| 2478 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</span></a>") | |
| 1846 | 2479 | if templ_7745c5c3_Err != nil { | |
| 1847 | 2480 | return templ_7745c5c3_Err | |
| 1848 | 2481 | } | |
| @@ -1868,40 +2501,40 @@ func MRChecks(c ChecksSection) templ.Component { | |||
| 1868 | 2501 | }() | |
| 1869 | 2502 | } | |
| 1870 | 2503 | ctx = templ.InitializeContext(ctx) | |
| 1871 | - | templ_7745c5c3_Var99 := templ.GetChildren(ctx) | |
| 1872 | - | if templ_7745c5c3_Var99 == nil { | |
| 1873 | - | templ_7745c5c3_Var99 = templ.NopComponent | |
| 2504 | + | templ_7745c5c3_Var131 := templ.GetChildren(ctx) | |
| 2505 | + | if templ_7745c5c3_Var131 == nil { | |
| 2506 | + | templ_7745c5c3_Var131 = templ.NopComponent | |
| 1874 | 2507 | } | |
| 1875 | 2508 | ctx = templ.ClearChildren(ctx) | |
| 1876 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |
| 2509 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |
| 1877 | 2510 | if templ_7745c5c3_Err != nil { | |
| 1878 | 2511 | return templ_7745c5c3_Err | |
| 1879 | 2512 | } | |
| 1880 | - | var templ_7745c5c3_Var100 string | |
| 1881 | - | templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |
| 2513 | + | var templ_7745c5c3_Var132 string | |
| 2514 | + | templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |
| 1882 | 2515 | if templ_7745c5c3_Err != nil { | |
| 1883 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 253, Col: 66} | |
| 2516 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 359, Col: 66} | |
| 1884 | 2517 | } | |
| 1885 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) | |
| 2518 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132)) | |
| 1886 | 2519 | if templ_7745c5c3_Err != nil { | |
| 1887 | 2520 | return templ_7745c5c3_Err | |
| 1888 | 2521 | } | |
| 1889 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span></h2>") | |
| 2522 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span></h2>") | |
| 1890 | 2523 | if templ_7745c5c3_Err != nil { | |
| 1891 | 2524 | return templ_7745c5c3_Err | |
| 1892 | 2525 | } | |
| 1893 | 2526 | if len(c.Rows) == 0 { | |
| 1894 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<p class=\"dim\">no checks</p>") | |
| 2527 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<p class=\"dim\">no checks</p>") | |
| 1895 | 2528 | if templ_7745c5c3_Err != nil { | |
| 1896 | 2529 | return templ_7745c5c3_Err | |
| 1897 | 2530 | } | |
| 1898 | 2531 | } else { | |
| 1899 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<ul class=\"checks\">") | |
| 2532 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<ul class=\"checks\">") | |
| 1900 | 2533 | if templ_7745c5c3_Err != nil { | |
| 1901 | 2534 | return templ_7745c5c3_Err | |
| 1902 | 2535 | } | |
| 1903 | 2536 | for _, r := range c.Rows { | |
| 1904 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li>") | |
| 2537 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "<li>") | |
| 1905 | 2538 | if templ_7745c5c3_Err != nil { | |
| 1906 | 2539 | return templ_7745c5c3_Err | |
| 1907 | 2540 | } | |
| @@ -1910,108 +2543,108 @@ func MRChecks(c ChecksSection) templ.Component { | |||
| 1910 | 2543 | return templ_7745c5c3_Err | |
| 1911 | 2544 | } | |
| 1912 | 2545 | if r.Href != "" { | |
| 1913 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<a href=\"") | |
| 2546 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<a href=\"") | |
| 1914 | 2547 | if templ_7745c5c3_Err != nil { | |
| 1915 | 2548 | return templ_7745c5c3_Err | |
| 1916 | 2549 | } | |
| 1917 | - | var templ_7745c5c3_Var101 templ.SafeURL | |
| 1918 | - | templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 2550 | + | var templ_7745c5c3_Var133 templ.SafeURL | |
| 2551 | + | templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 1919 | 2552 | if templ_7745c5c3_Err != nil { | |
| 1920 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 38} | |
| 2553 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 368, Col: 38} | |
| 1921 | 2554 | } | |
| 1922 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) | |
| 2555 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133)) | |
| 1923 | 2556 | if templ_7745c5c3_Err != nil { | |
| 1924 | 2557 | return templ_7745c5c3_Err | |
| 1925 | 2558 | } | |
| 1926 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\">") | |
| 2559 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "\">") | |
| 1927 | 2560 | if templ_7745c5c3_Err != nil { | |
| 1928 | 2561 | return templ_7745c5c3_Err | |
| 1929 | 2562 | } | |
| 1930 | - | var templ_7745c5c3_Var102 string | |
| 1931 | - | templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 2563 | + | var templ_7745c5c3_Var134 string | |
| 2564 | + | templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1932 | 2565 | if templ_7745c5c3_Err != nil { | |
| 1933 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 49} | |
| 2566 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 368, Col: 49} | |
| 1934 | 2567 | } | |
| 1935 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) | |
| 2568 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134)) | |
| 1936 | 2569 | if templ_7745c5c3_Err != nil { | |
| 1937 | 2570 | return templ_7745c5c3_Err | |
| 1938 | 2571 | } | |
| 1939 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</a> ") | |
| 2572 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</a> ") | |
| 1940 | 2573 | if templ_7745c5c3_Err != nil { | |
| 1941 | 2574 | return templ_7745c5c3_Err | |
| 1942 | 2575 | } | |
| 1943 | 2576 | } else { | |
| 1944 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>") | |
| 2577 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "<span>") | |
| 1945 | 2578 | if templ_7745c5c3_Err != nil { | |
| 1946 | 2579 | return templ_7745c5c3_Err | |
| 1947 | 2580 | } | |
| 1948 | - | var templ_7745c5c3_Var103 string | |
| 1949 | - | templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 2581 | + | var templ_7745c5c3_Var135 string | |
| 2582 | + | templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1950 | 2583 | if templ_7745c5c3_Err != nil { | |
| 1951 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 264, Col: 21} | |
| 2584 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 370, Col: 21} | |
| 1952 | 2585 | } | |
| 1953 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) | |
| 2586 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135)) | |
| 1954 | 2587 | if templ_7745c5c3_Err != nil { | |
| 1955 | 2588 | return templ_7745c5c3_Err | |
| 1956 | 2589 | } | |
| 1957 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ") | |
| 2590 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</span> ") | |
| 1958 | 2591 | if templ_7745c5c3_Err != nil { | |
| 1959 | 2592 | return templ_7745c5c3_Err | |
| 1960 | 2593 | } | |
| 1961 | 2594 | } | |
| 1962 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"dim\">") | |
| 2595 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<span class=\"dim\">") | |
| 1963 | 2596 | if templ_7745c5c3_Err != nil { | |
| 1964 | 2597 | return templ_7745c5c3_Err | |
| 1965 | 2598 | } | |
| 1966 | - | var templ_7745c5c3_Var104 string | |
| 1967 | - | templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 2599 | + | var templ_7745c5c3_Var136 string | |
| 2600 | + | templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 1968 | 2601 | if templ_7745c5c3_Err != nil { | |
| 1969 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 33} | |
| 2602 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 372, Col: 33} | |
| 1970 | 2603 | } | |
| 1971 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) | |
| 2604 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136)) | |
| 1972 | 2605 | if templ_7745c5c3_Err != nil { | |
| 1973 | 2606 | return templ_7745c5c3_Err | |
| 1974 | 2607 | } | |
| 1975 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</span> ") | |
| 2608 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</span> ") | |
| 1976 | 2609 | if templ_7745c5c3_Err != nil { | |
| 1977 | 2610 | return templ_7745c5c3_Err | |
| 1978 | 2611 | } | |
| 1979 | 2612 | if r.Waiting { | |
| 1980 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<span class=\"mute\">waiting for a runner</span>") | |
| 2613 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "<span class=\"mute\">waiting for a runner</span>") | |
| 1981 | 2614 | if templ_7745c5c3_Err != nil { | |
| 1982 | 2615 | return templ_7745c5c3_Err | |
| 1983 | 2616 | } | |
| 1984 | 2617 | } | |
| 1985 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</li>") | |
| 2618 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "</li>") | |
| 1986 | 2619 | if templ_7745c5c3_Err != nil { | |
| 1987 | 2620 | return templ_7745c5c3_Err | |
| 1988 | 2621 | } | |
| 1989 | 2622 | } | |
| 1990 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</ul>") | |
| 2623 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</ul>") | |
| 1991 | 2624 | if templ_7745c5c3_Err != nil { | |
| 1992 | 2625 | return templ_7745c5c3_Err | |
| 1993 | 2626 | } | |
| 1994 | 2627 | } | |
| 1995 | 2628 | if c.Note != "" { | |
| 1996 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<p class=\"mute\">") | |
| 2629 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<p class=\"mute\">") | |
| 1997 | 2630 | if templ_7745c5c3_Err != nil { | |
| 1998 | 2631 | return templ_7745c5c3_Err | |
| 1999 | 2632 | } | |
| 2000 | - | var templ_7745c5c3_Var105 string | |
| 2001 | - | templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | |
| 2633 | + | var templ_7745c5c3_Var137 string | |
| 2634 | + | templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | |
| 2002 | 2635 | if templ_7745c5c3_Err != nil { | |
| 2003 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 275, Col: 27} | |
| 2636 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 381, Col: 27} | |
| 2004 | 2637 | } | |
| 2005 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) | |
| 2638 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137)) | |
| 2006 | 2639 | if templ_7745c5c3_Err != nil { | |
| 2007 | 2640 | return templ_7745c5c3_Err | |
| 2008 | 2641 | } | |
| 2009 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>") | |
| 2642 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</p>") | |
| 2010 | 2643 | if templ_7745c5c3_Err != nil { | |
| 2011 | 2644 | return templ_7745c5c3_Err | |
| 2012 | 2645 | } | |
| 2013 | 2646 | } | |
| 2014 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</section>") | |
| 2647 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</section>") | |
| 2015 | 2648 | if templ_7745c5c3_Err != nil { | |
| 2016 | 2649 | return templ_7745c5c3_Err | |
| 2017 | 2650 | } | |
M internal/adapters/web/actions_test.go +1 -0
| @@ -38,6 +38,7 @@ func TestLogPagesPassTheClientKey(t *testing.T) { | |||
| 38 | 38 | runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{ | |
| 39 | 39 | Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1, | |
| 40 | 40 | }, nil).Maybe() | |
| 41 | + | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{}, nil).Maybe() | |
| 41 | 42 | var seen []string | |
| 42 | 43 | runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn( | |
| 43 | 44 | 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) { | |||
| 120 | 120 | } | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 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 | + | ||
| 123 | 139 | // The watch runs in a goroutine and hands each event to this one through | |
| 124 | 140 | // an unbuffered channel: a visitor that reads slowly holds the watch in | |
| 125 | 141 | // 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) { | |||
| 202 | 218 | return | |
| 203 | 219 | } | |
| 204 | 220 | ||
| 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} | |
| 206 | 222 | handle := func(m recvd) bool { | |
| 207 | 223 | if m.err != nil { | |
| 208 | 224 | 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 { | |||
| 26 | 26 | head string // commits: the commit listed first | |
| 27 | 27 | n string // mr: the number of the merge request shown; run: the run id | |
| 28 | 28 | 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 | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | // renderFragment renders a component to the data of an event. | |
| @@ -478,6 +481,9 @@ func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) { | |||
| 478 | 481 | // jobState tells the job page that its state or its steps changed: the page | |
| 479 | 482 | // reloads the part of the log after the last line it holds (ermite.js). | |
| 480 | 483 | 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 | + | } | |
| 481 | 487 | d, err := l.s.loadRepo(ctx, l.repo) | |
| 482 | 488 | if err != nil { | |
| 483 | 489 | return nil, err | |
| @@ -496,5 +502,13 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |||
| 496 | 502 | if err != nil { | |
| 497 | 503 | return nil, err | |
| 498 | 504 | } | |
| 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 | |
| 500 | 514 | } | |
M internal/adapters/web/markdown.go +17 -5
| @@ -55,9 +55,16 @@ func newMarkdown() *markdown { | |||
| 55 | 55 | // are where relative links point; dir is the directory of the Markdown file | |
| 56 | 56 | // in the repository, "" for the root. | |
| 57 | 57 | 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()})) | |
| 59 | 66 | 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} | |
| 61 | 68 | pol.apply(doc) | |
| 62 | 69 | ||
| 63 | 70 | var buf bytes.Buffer | |
| @@ -70,10 +77,13 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, | |||
| 70 | 77 | } | |
| 71 | 78 | ||
| 72 | 79 | // 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 | + | } | |
| 74 | 84 | ||
| 75 | 85 | 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)...) | |
| 77 | 87 | } | |
| 78 | 88 | ||
| 79 | 89 | func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) } | |
| @@ -119,6 +129,8 @@ func plainText(n ast.Node, source []byte) string { | |||
| 119 | 129 | type linkPolicy struct { | |
| 120 | 130 | repo, ref, dir string | |
| 121 | 131 | source []byte | |
| 132 | + | // idPrefix is the prefix of the heading ids that #fragment links point at. | |
| 133 | + | idPrefix string | |
| 122 | 134 | } | |
| 123 | 135 | ||
| 124 | 136 | func (p linkPolicy) apply(doc ast.Node) { | |
| @@ -202,7 +214,7 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 202 | 214 | if frag == "" || strings.ContainsAny(frag, " \"'<>") { | |
| 203 | 215 | return "", dropped | |
| 204 | 216 | } | |
| 205 | - | return "#" + headingIDPrefix + frag, internal | |
| 217 | + | return "#" + p.idPrefix + frag, internal | |
| 206 | 218 | } | |
| 207 | 219 | if strings.HasPrefix(dest, "//") { | |
| 208 | 220 | return "", dropped | |
M internal/adapters/web/static/css/65-ci.css +29 -0
| @@ -37,6 +37,35 @@ | |||
| 37 | 37 | .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); } | |
| 38 | 38 | .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); } | |
| 39 | 39 | ||
| 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 | + | ||
| 40 | 69 | .joblog { margin-top: var(--s4); } | |
| 41 | 70 | .log { | |
| 42 | 71 | 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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | 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) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
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 | + | "", | |
| 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 <i>failed</i>", "a title is text") | |
| 388 | + | assert.Contains(t, run.body, "unused <img src=x onerror=alert(1)>") | |
| 389 | + | assert.NotContains(t, run.body, evil) | |
| 390 | + | assert.Contains(t, run.body, "Run <b>vet</b>", "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 <img src=x onerror=alert(1)>") | |
| 419 | + | assert.NotContains(t, jobPage.body, evil) | |
| 420 | + | assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused <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 <b>warning</b>") { | |
| 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) { | |||
| 326 | 326 | ver := decode[versionOut](t, res.stdout) | |
| 327 | 327 | assert.Equal(t, c.url, ver.Server.URL) | |
| 328 | 328 | 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) | |
| 330 | 330 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 331 | 331 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 332 | 332 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_run.go +238 -15
| @@ -40,10 +40,13 @@ type jsonRun struct { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | 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"` | |
| 47 | 50 | StartedAt *string `json:"started_at"` | |
| 48 | 51 | CompletedAt *string `json:"completed_at"` | |
| 49 | 52 | LogStart int64 `json:"log_start"` | |
| @@ -67,10 +70,28 @@ type jsonRunJob struct { | |||
| 67 | 70 | CompletedAt *string `json:"completed_at"` | |
| 68 | 71 | } | |
| 69 | 72 | ||
| 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 | + | ||
| 70 | 88 | type jsonRunDetail struct { | |
| 71 | 89 | 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"` | |
| 74 | 95 | } | |
| 75 | 96 | ||
| 76 | 97 | const jsonRunHelp = ` | |
| @@ -79,7 +100,9 @@ ref, sha, actor, status (queued, in_progress, completed), conclusion (success, f | |||
| 79 | 100 | skipped, timed_out, neutral, action_required; empty until completed), error, created_at, | |
| 80 | 101 | started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and | |
| 81 | 102 | 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.` | |
| 83 | 106 | ||
| 84 | 107 | func statusText(s ermite.CiStatus) string { | |
| 85 | 108 | return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_")) | |
| @@ -111,6 +134,15 @@ func runResult(status ermite.CiStatus, c ermite.CiConclusion) string { | |||
| 111 | 134 | return statusText(status) | |
| 112 | 135 | } | |
| 113 | 136 | ||
| 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 | + | ||
| 114 | 146 | func toJSONRun(r *ermite.Run) jsonRun { | |
| 115 | 147 | return jsonRun{ | |
| 116 | 148 | 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 { | |||
| 125 | 157 | for _, s := range j.GetSteps() { | |
| 126 | 158 | steps = append(steps, jsonStep{ | |
| 127 | 159 | 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(), | |
| 129 | 161 | }) | |
| 130 | 162 | } | |
| 131 | 163 | labels, needs := j.GetLabels(), j.GetNeeds() | |
| @@ -254,21 +286,27 @@ func parseStatusFlag(s string) (ermite.CiStatus, error) { | |||
| 254 | 286 | ||
| 255 | 287 | func newRunViewCmd(env *cliEnv) *cobra.Command { | |
| 256 | 288 | var attempt int32 | |
| 257 | - | var logFlag bool | |
| 289 | + | var logFlag, summaryFlag bool | |
| 258 | 290 | var jobFlag string | |
| 259 | 291 | cmd := &cobra.Command{ | |
| 260 | 292 | 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" + | |
| 263 | 296 | "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, | |
| 265 | 300 | Args: cobra.ExactArgs(2), | |
| 266 | 301 | ValidArgsFunction: completeRepoNames(env), | |
| 267 | 302 | } | |
| 268 | 303 | cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)") | |
| 269 | 304 | 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)") | |
| 271 | 307 | asJSON := addJSONFlag(cmd) | |
| 308 | + | cmd.MarkFlagsMutuallyExclusive("log", "summary") | |
| 309 | + | cmd.MarkFlagsMutuallyExclusive("json", "summary") | |
| 272 | 310 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 273 | 311 | id, err := parseRunID(args[1]) | |
| 274 | 312 | if err != nil { | |
| @@ -286,14 +324,30 @@ func newRunViewCmd(env *cliEnv) *cobra.Command { | |||
| 286 | 324 | if logFlag { | |
| 287 | 325 | return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false) | |
| 288 | 326 | } | |
| 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 | + | } | |
| 289 | 334 | 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 | + | } | |
| 291 | 339 | for _, j := range res.Msg.GetJobs() { | |
| 292 | 340 | d.Jobs = append(d.Jobs, toJSONRunJob(j)) | |
| 293 | 341 | } | |
| 342 | + | for _, a := range anns.items { | |
| 343 | + | d.Annotations = append(d.Annotations, toJSONAnnotation(a, anns.jobs)) | |
| 344 | + | } | |
| 294 | 345 | return writeJSON(cmd.OutOrStdout(), d) | |
| 295 | 346 | } | |
| 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) | |
| 297 | 351 | } | |
| 298 | 352 | return cmd | |
| 299 | 353 | } | |
| @@ -340,6 +394,175 @@ func printRun(w io.Writer, r *ermite.GetRunResponse) error { | |||
| 340 | 394 | return nil | |
| 341 | 395 | } | |
| 342 | 396 | ||
| 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 | + | ||
| 343 | 566 | // pickJobs selects the jobs --job names (an id or a name), or all of them. | |
| 344 | 567 | func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) { | |
| 345 | 568 | if sel == "" { | |
M internal/app/forge.go +2 -1
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | 18 | "bnema.dev/ermite/internal/adapters/cilogs" | |
| 19 | + | "bnema.dev/ermite/internal/adapters/cisummaries" | |
| 19 | 20 | "bnema.dev/ermite/internal/adapters/ermitegit" | |
| 20 | 21 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 21 | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| @@ -212,7 +213,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 212 | 213 | // CI (ADR 0025): the engine reads workflows through the reader, and plans | |
| 213 | 214 | // them in the background; a push only queues its refs. | |
| 214 | 215 | 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, | |
| 216 | 217 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 217 | 218 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 218 | 219 | PollTimeout: cfg.CIPollTimeout, Archive: git, | |
M internal/domain/ci.go +8 -4
| @@ -198,10 +198,14 @@ type CIRunSource struct { | |||
| 198 | 198 | ||
| 199 | 199 | // CIStep is a step of a job with where its lines are in the job log. | |
| 200 | 200 | 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 | |
| 205 | 209 | StartedAt time.Time | |
| 206 | 210 | CompletedAt time.Time | |
| 207 | 211 | 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 { | |||
| 30 | 30 | // exist yet), RevokeJobs the jobs whose repository tokens are deleted by it, | |
| 31 | 31 | // and Resolutions the action resolutions of a run attempt to keep. They are | |
| 32 | 32 | // 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. | |
| 33 | 38 | 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 | |
| 40 | 57 | } | |
| 41 | 58 | ||
| 42 | 59 | // CIJobRef names one execution of a job. | |
| @@ -84,6 +101,10 @@ type CIStore interface { | |||
| 84 | 101 | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 85 | 102 | // ListOpenRuns returns the runs that have not completed. | |
| 86 | 103 | 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) | |
| 87 | 108 | // GetJob returns a job of a run at an attempt of the run, or | |
| 88 | 109 | // domain.ErrNotFound. | |
| 89 | 110 | GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) | |
| @@ -122,8 +143,8 @@ type CIStore interface { | |||
| 122 | 143 | } | |
| 123 | 144 | ||
| 124 | 145 | // 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. | |
| 127 | 148 | type CIRunCleaner interface { | |
| 128 | 149 | RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |
| 129 | 150 | RemoveLogs(ctx context.Context, runIDs []int64) error | |
| @@ -138,7 +159,8 @@ type CIChecksReader interface { | |||
| 138 | 159 | CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) | |
| 139 | 160 | } | |
| 140 | 161 | ||
| 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. | |
| 142 | 164 | type CILogRef struct { | |
| 143 | 165 | RunID int64 | |
| 144 | 166 | JobID string | |
| @@ -188,6 +210,29 @@ type CILogStore interface { | |||
| 188 | 210 | RunIDs(ctx context.Context) ([]int64, error) | |
| 189 | 211 | } | |
| 190 | 212 | ||
| 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 | + | ||
| 191 | 236 | // WorkflowTrigger starts the workflows that match pushed refs. OnPush only | |
| 192 | 237 | // queues the work and returns: planning happens in the background and its | |
| 193 | 238 | // errors never reach the push. | |
| @@ -258,10 +303,13 @@ type FetchJobResult struct { | |||
| 258 | 303 | ||
| 259 | 304 | // StepUpdate is the state of one step as a runner reports it. | |
| 260 | 305 | 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 | |
| 265 | 313 | StartedAt time.Time | |
| 266 | 314 | CompletedAt time.Time | |
| 267 | 315 | } | |
| @@ -273,6 +321,8 @@ type CompleteJobParams struct { | |||
| 273 | 321 | Outputs map[string]string | |
| 274 | 322 | RunnerShutdown bool | |
| 275 | 323 | Steps []StepUpdate | |
| 324 | + | // Annotations replace those of each step they name, as in UpdateJob. | |
| 325 | + | Annotations []domain.CIAnnotation | |
| 276 | 326 | } | |
| 277 | 327 | ||
| 278 | 328 | // CompleteJobResult is the stored result of a job. | |
| @@ -337,7 +387,10 @@ type JobCancel struct { | |||
| 337 | 387 | type RunnerService interface { | |
| 338 | 388 | Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error) | |
| 339 | 389 | 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 | |
| 341 | 394 | AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error) | |
| 342 | 395 | Heartbeat(ctx context.Context, secret, jobID string) error | |
| 343 | 396 | // WatchJob calls ready once the job token is valid and the stream is | |
| @@ -350,6 +403,10 @@ type RunnerService interface { | |||
| 350 | 403 | // an error: its answer carries Error. A local ("./") or Docker | |
| 351 | 404 | // ("docker://") reference, or more than 100, is domain.ErrInvalid. | |
| 352 | 405 | 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 | |
| 353 | 410 | } | |
| 354 | 411 | ||
| 355 | 412 | // JobView is a job as owners see it. | |
| @@ -445,6 +502,25 @@ type RunnerView struct { | |||
| 445 | 502 | Busy bool | |
| 446 | 503 | } | |
| 447 | 504 | ||
| 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 | + | ||
| 448 | 524 | // RunService is the owner side of CI. *usecase.CI implements it. | |
| 449 | 525 | type RunService interface { | |
| 450 | 526 | ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error) | |
| @@ -454,6 +530,15 @@ type RunService interface { | |||
| 454 | 530 | CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error) | |
| 455 | 531 | RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) | |
| 456 | 532 | 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) | |
| 457 | 542 | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | |
| 458 | 543 | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | |
| 459 | 544 | } | |
M internal/ports/mocks/CIStore.go +80 -0
| @@ -765,6 +765,86 @@ func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id | |||
| 765 | 765 | return _c | |
| 766 | 766 | } | |
| 767 | 767 | ||
| 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 | + | ||
| 768 | 848 | // ListActiveJobs provides a mock function for the type CIStore | |
| 769 | 849 | func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) { | |
| 770 | 850 | 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 | |||
| 369 | 369 | return _c | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 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 | + | ||
| 372 | 552 | // ListChecks provides a mock function for the type RunService | |
| 373 | 553 | func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) { | |
| 374 | 554 | 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 | |||
| 498 | 498 | } | |
| 499 | 499 | ||
| 500 | 500 | // 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) | |
| 503 | 503 | ||
| 504 | 504 | if len(ret) == 0 { | |
| 505 | 505 | panic("no return value specified for UpdateJob") | |
| 506 | 506 | } | |
| 507 | 507 | ||
| 508 | 508 | 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) | |
| 511 | 511 | } else { | |
| 512 | 512 | r0 = ret.Error(0) | |
| 513 | 513 | } | |
| @@ -524,11 +524,12 @@ type RunnerService_UpdateJob_Call struct { | |||
| 524 | 524 | // - secret string | |
| 525 | 525 | // - jobID string | |
| 526 | 526 | // - 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)} | |
| 529 | 530 | } | |
| 530 | 531 | ||
| 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 { | |
| 532 | 533 | _c.Call.Run(func(args mock.Arguments) { | |
| 533 | 534 | var arg0 context.Context | |
| 534 | 535 | if args[0] != nil { | |
| @@ -546,11 +547,16 @@ func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret | |||
| 546 | 547 | if args[3] != nil { | |
| 547 | 548 | arg3 = args[3].([]ports.StepUpdate) | |
| 548 | 549 | } | |
| 550 | + | var arg4 []domain.CIAnnotation | |
| 551 | + | if args[4] != nil { | |
| 552 | + | arg4 = args[4].([]domain.CIAnnotation) | |
| 553 | + | } | |
| 549 | 554 | run( | |
| 550 | 555 | arg0, | |
| 551 | 556 | arg1, | |
| 552 | 557 | arg2, | |
| 553 | 558 | arg3, | |
| 559 | + | arg4, | |
| 554 | 560 | ) | |
| 555 | 561 | }) | |
| 556 | 562 | return _c | |
| @@ -561,7 +567,82 @@ func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJ | |||
| 561 | 567 | return _c | |
| 562 | 568 | } | |
| 563 | 569 | ||
| 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 { | |
| 565 | 646 | _c.Call.Return(run) | |
| 566 | 647 | return _c | |
| 567 | 648 | } | |
M internal/usecase/ci.go +20 -11
| @@ -46,19 +46,21 @@ const ( | |||
| 46 | 46 | maxCIWorkflowFiles = 256 | |
| 47 | 47 | ) | |
| 48 | 48 | ||
| 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, | |
| 50 | 50 | // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies | |
| 51 | 51 | // the defaults of Owner and Retention). | |
| 52 | 52 | 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 | |
| 62 | 64 | // Auth re-validates the token of a log stream; nil skips it. | |
| 63 | 65 | Auth ports.Revalidator | |
| 64 | 66 | // Remote lists the refs of github.com repositories for ResolveActions; nil | |
| @@ -295,6 +297,10 @@ type runState struct { | |||
| 295 | 297 | revoke []ports.CIJobRef | |
| 296 | 298 | // resolutions are the action resolutions to keep with the next flush. | |
| 297 | 299 | 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 | |
| 298 | 304 | } | |
| 299 | 305 | ||
| 300 | 306 | // 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 { | |||
| 379 | 385 | w.Runs = []domain.CIRun{rs.run} | |
| 380 | 386 | } | |
| 381 | 387 | 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 { | |
| 383 | 391 | return nil | |
| 384 | 392 | } | |
| 385 | 393 | v, err := c.cfg.Store.Apply(ctx, w) | |
| @@ -395,6 +403,7 @@ func (rs *runState) flush(ctx context.Context) error { | |||
| 395 | 403 | changedRun := rs.runDirty | |
| 396 | 404 | rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false | |
| 397 | 405 | rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil | |
| 406 | + | rs.annotations, rs.clear = nil, nil | |
| 398 | 407 | ||
| 399 | 408 | repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo) | |
| 400 | 409 | 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 | |||
| 231 | 231 | if s.State == domain.CIStepPending && !cancelish { | |
| 232 | 232 | s.Conclusion = domain.CISkipped | |
| 233 | 233 | } | |
| 234 | + | s.Outcome = s.Conclusion | |
| 234 | 235 | s.State, s.CompletedAt = domain.CIStepCompleted, now | |
| 235 | 236 | } | |
| 236 | 237 | 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 { | |||
| 321 | 321 | return nil | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 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}) | |
| 328 | 329 | if err != nil { | |
| 329 | 330 | return fmt.Errorf("write job %s: %w", j.ID, err) | |
| 330 | 331 | } | |
| @@ -341,8 +342,10 @@ func logRef(j domain.CIJob) ports.CILogRef { | |||
| 341 | 342 | return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt} | |
| 342 | 343 | } | |
| 343 | 344 | ||
| 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 { | |
| 346 | 349 | j, err := c.jobFor(ctx, secret, jobID) | |
| 347 | 350 | if err != nil { | |
| 348 | 351 | return err | |
| @@ -353,16 +356,26 @@ func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports. | |||
| 353 | 356 | if err := checkStepUpdates(steps); err != nil { | |
| 354 | 357 | return err | |
| 355 | 358 | } | |
| 359 | + | if err := domain.CheckAnnotations(annotations); err != nil { | |
| 360 | + | return err | |
| 361 | + | } | |
| 356 | 362 | return c.lockedJob(ctx, j, func(cur *domain.CIJob) error { | |
| 357 | 363 | if cur.Ended() { | |
| 358 | 364 | return endedError(*cur) | |
| 359 | 365 | } | |
| 360 | 366 | next, changed, err := applySteps(cur.Steps, steps) | |
| 361 | - | if err != nil || !changed { | |
| 367 | + | if err != nil { | |
| 362 | 368 | return err | |
| 363 | 369 | } | |
| 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 | + | } | |
| 364 | 377 | cur.Steps = next | |
| 365 | - | return c.saveJob(ctx, cur) | |
| 378 | + | return c.saveJob(ctx, cur, writes...) | |
| 366 | 379 | }) | |
| 367 | 380 | } | |
| 368 | 381 | ||
| @@ -377,12 +390,12 @@ func checkStepUpdates(steps []ports.StepUpdate) error { | |||
| 377 | 390 | } | |
| 378 | 391 | switch s.State { | |
| 379 | 392 | 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) | |
| 382 | 395 | } | |
| 383 | 396 | 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) | |
| 386 | 399 | } | |
| 387 | 400 | case domain.CIStepCompleted: | |
| 388 | 401 | switch s.Conclusion { | |
| @@ -390,6 +403,11 @@ func checkStepUpdates(steps []ports.StepUpdate) error { | |||
| 390 | 403 | default: | |
| 391 | 404 | return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number) | |
| 392 | 405 | } | |
| 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 | + | } | |
| 393 | 411 | if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped { | |
| 394 | 412 | return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number) | |
| 395 | 413 | } | |
| @@ -413,21 +431,22 @@ func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep, | |||
| 413 | 431 | i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number }) | |
| 414 | 432 | if i < 0 { | |
| 415 | 433 | 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, | |
| 417 | 436 | }) | |
| 418 | 437 | changed = true | |
| 419 | 438 | continue | |
| 420 | 439 | } | |
| 421 | 440 | cur := out[i] | |
| 422 | 441 | 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 | |
| 424 | 443 | next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt | |
| 425 | 444 | if cur.Name != "" && name == "" { | |
| 426 | 445 | next.Name = cur.Name // an empty name never erases one | |
| 427 | 446 | } | |
| 428 | 447 | switch { | |
| 429 | 448 | 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 && | |
| 431 | 450 | next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt) | |
| 432 | 451 | if !same { | |
| 433 | 452 | 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, | |||
| 444 | 463 | return out, changed, nil | |
| 445 | 464 | } | |
| 446 | 465 | ||
| 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 | + | ||
| 447 | 475 | 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 && | |
| 449 | 477 | a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines | |
| 450 | 478 | } | |
| 451 | 479 | ||
| @@ -731,6 +759,9 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple | |||
| 731 | 759 | if err := checkStepUpdates(params.Steps); err != nil { | |
| 732 | 760 | return ports.CompleteJobResult{}, err | |
| 733 | 761 | } | |
| 762 | + | if err := domain.CheckAnnotations(params.Annotations); err != nil { | |
| 763 | + | return ports.CompleteJobResult{}, err | |
| 764 | + | } | |
| 734 | 765 | var res ports.CompleteJobResult | |
| 735 | 766 | err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error { | |
| 736 | 767 | cur := rs.find(j.ID) | |
| @@ -748,6 +779,11 @@ func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.Comple | |||
| 748 | 779 | if err != nil { | |
| 749 | 780 | return err | |
| 750 | 781 | } | |
| 782 | + | writes, err := c.annotationWrites(ctx, *cur, params.Annotations) | |
| 783 | + | if err != nil { | |
| 784 | + | return err | |
| 785 | + | } | |
| 786 | + | rs.annotations = append(rs.annotations, writes...) | |
| 751 | 787 | cur.Steps = steps | |
| 752 | 788 | cur.Outputs = params.Outputs | |
| 753 | 789 | msg := "" | |
M internal/usecase/ci_runner_unit_test.go +13 -4
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | type ciUnit struct { | |
| 25 | 25 | store *mocks.CIStore | |
| 26 | 26 | logs *mocks.CILogStore | |
| 27 | + | sums *mocks.CISummaryStore | |
| 27 | 28 | meta *mocks.RepoMetaStore | |
| 28 | 29 | bus *mocks.EventBus | |
| 29 | 30 | clock *fakeClock | |
| @@ -37,11 +38,11 @@ func (c *fakeClock) Now() time.Time { return c.t } | |||
| 37 | 38 | func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit { | |
| 38 | 39 | t.Helper() | |
| 39 | 40 | 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), | |
| 41 | 42 | bus: mocks.NewEventBus(t), clock: &fakeClock{t: now}, | |
| 42 | 43 | } | |
| 43 | 44 | 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), | |
| 45 | 46 | Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test", | |
| 46 | 47 | } | |
| 47 | 48 | if tweak != nil { | |
| @@ -83,7 +84,7 @@ func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) { | |||
| 83 | 84 | "UpdateJob": func(u *ciUnit) error { | |
| 84 | 85 | return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{ | |
| 85 | 86 | Number: 1, State: domain.CIStepInProgress, StartedAt: now, | |
| 86 | - | }}) | |
| 87 | + | }}, nil) | |
| 87 | 88 | }, | |
| 88 | 89 | "AppendLog": func(u *ciUnit) error { | |
| 89 | 90 | _, 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) { | |||
| 122 | 123 | ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled | |
| 123 | 124 | u.expectToken(jobSecret, j) | |
| 124 | 125 | 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) | |
| 126 | 127 | got, ok := domain.IsJobEnded(err) | |
| 127 | 128 | require.True(t, ok, "%v", err) | |
| 128 | 129 | assert.Equal(t, domain.CICancelled, got.Conclusion) | |
| @@ -311,6 +312,10 @@ func TestSweep(t *testing.T) { | |||
| 311 | 312 | ctx, out := logCapture(t) | |
| 312 | 313 | u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil) | |
| 313 | 314 | 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) | |
| 314 | 319 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil) | |
| 315 | 320 | u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil) | |
| 316 | 321 | u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| @@ -337,6 +342,8 @@ func TestSweep(t *testing.T) { | |||
| 337 | 342 | ctx, out := logCapture(t) | |
| 338 | 343 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil) | |
| 339 | 344 | 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) | |
| 340 | 347 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 341 | 348 | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound) | |
| 342 | 349 | u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once() | |
| @@ -353,6 +360,7 @@ func TestSweep(t *testing.T) { | |||
| 353 | 360 | ctx, out := logCapture(t) | |
| 354 | 361 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 355 | 362 | 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")) | |
| 356 | 364 | u.ci.Sweep(ctx) | |
| 357 | 365 | assert.Contains(t, out.String(), "list the log directories") | |
| 358 | 366 | }) | |
| @@ -363,6 +371,7 @@ func TestSweep(t *testing.T) { | |||
| 363 | 371 | ctx, out := logCapture(t) | |
| 364 | 372 | u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil) | |
| 365 | 373 | u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil) | |
| 374 | + | u.sums.EXPECT().RunIDs(mock.Anything).Return(nil, nil) | |
| 366 | 375 | u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed")) | |
| 367 | 376 | u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing | |
| 368 | 377 | 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) } | |||
| 185 | 185 | func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) { | |
| 186 | 186 | ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled") | |
| 187 | 187 | _ = c.cfg.Logs.Reset(ctx, logRef(*j)) | |
| 188 | + | _ = c.cfg.Summaries.RemoveJob(ctx, logRef(*j)) | |
| 189 | + | rs.clear = append(rs.clear, logRef(*j)) | |
| 188 | 190 | j.AssignCancels++ | |
| 189 | 191 | j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{} | |
| 190 | 192 | j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone | |
| @@ -252,14 +254,25 @@ func (c *CI) sweep(ctx context.Context) { | |||
| 252 | 254 | if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil { | |
| 253 | 255 | log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs") | |
| 254 | 256 | } | |
| 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 | + | } | |
| 255 | 260 | for _, id := range ids { | |
| 256 | 261 | c.locks.Delete(id) | |
| 257 | 262 | } | |
| 258 | 263 | log.Info().Int("runs", len(ids)).Msg("ci: old runs removed") | |
| 259 | 264 | } | |
| 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) | |
| 261 | 274 | if err != nil { | |
| 262 | - | log.Error().Err(err).Msg("ci: list the log directories") | |
| 275 | + | log.Error().Err(err).Msg("ci: list the " + dirs) | |
| 263 | 276 | return | |
| 264 | 277 | } | |
| 265 | 278 | var orphans []int64 | |
| @@ -268,12 +281,12 @@ func (c *CI) sweep(ctx context.Context) { | |||
| 268 | 281 | case errors.Is(err, domain.ErrNotFound): | |
| 269 | 282 | orphans = append(orphans, id) | |
| 270 | 283 | 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") | |
| 272 | 285 | } | |
| 273 | 286 | } | |
| 274 | 287 | 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") | |
| 277 | 290 | } | |
| 278 | 291 | } | |
| 279 | 292 | } | |
| @@ -284,10 +297,11 @@ func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) | |||
| 284 | 297 | return c.cfg.Store.RunIDs(ctx, repo) | |
| 285 | 298 | } | |
| 286 | 299 | ||
| 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. | |
| 288 | 302 | func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error { | |
| 289 | 303 | for _, id := range runIDs { | |
| 290 | 304 | c.locks.Delete(id) | |
| 291 | 305 | } | |
| 292 | - | return c.cfg.Logs.RemoveRuns(ctx, runIDs) | |
| 306 | + | return errors.Join(c.cfg.Logs.RemoveRuns(ctx, runIDs), c.cfg.Summaries.RemoveRuns(ctx, runIDs)) | |
| 293 | 307 | } | |
M proto/ermite/storage/ci.proto +31 -0
| @@ -135,6 +135,9 @@ message CIStep { | |||
| 135 | 135 | // count of the job: it is never stored. | |
| 136 | 136 | int64 log_start = 7; | |
| 137 | 137 | 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; | |
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | // CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>". | |
| @@ -260,3 +263,31 @@ message CIActionResolution { | |||
| 260 | 263 | // 1 = github.com, 2 = this server (the numbers of ActionSource). | |
| 261 | 264 | int32 source = 4; | |
| 262 | 265 | } | |
| 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 | + | } | |