fix(ci): bound the summaries a page reads and stop reading the run on live job updates
- commit
- 3bc6931d1790eb7bae68fe26f6f4676f9c6d259a
- author
- brice · · 2026-10-13 12:58 UTC
- parent
- 19fda0c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +734 -411
M internal/adapters/cisummaries/cisummaries.go +55 -2
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "context" | |
| 14 | 14 | "errors" | |
| 15 | 15 | "fmt" | |
| 16 | + | "io" | |
| 16 | 17 | "io/fs" | |
| 17 | 18 | "os" | |
| 18 | 19 | "path/filepath" | |
| @@ -89,7 +90,7 @@ func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown | |||
| 89 | 90 | } | |
| 90 | 91 | ||
| 91 | 92 | // List returns the summaries of the steps that have one, by step number. | |
| 92 | - | func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) { | |
| 93 | + | func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) { | |
| 93 | 94 | if !validRef(ref) { | |
| 94 | 95 | return nil, false, nil | |
| 95 | 96 | } | |
| @@ -118,19 +119,71 @@ func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain | |||
| 118 | 119 | steps = steps[:limit] | |
| 119 | 120 | } | |
| 120 | 121 | out := make([]domain.CIStepSummary, 0, len(steps)) | |
| 122 | + | var read int64 | |
| 123 | + | exhausted := false | |
| 121 | 124 | for _, n := range steps { | |
| 122 | - | b, err := os.ReadFile(s.path(ref, n)) | |
| 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) | |
| 123 | 145 | if errors.Is(err, fs.ErrNotExist) { | |
| 124 | 146 | continue // removed meanwhile | |
| 125 | 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 | + | } | |
| 126 | 153 | if err != nil { | |
| 127 | 154 | return nil, false, fmt.Errorf("read summary: %w", err) | |
| 128 | 155 | } | |
| 156 | + | read += int64(len(b)) | |
| 129 | 157 | out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)}) | |
| 130 | 158 | } | |
| 131 | 159 | return out, more, nil | |
| 132 | 160 | } | |
| 133 | 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 | + | ||
| 134 | 187 | // RemoveJob deletes the summaries of one execution of a job. | |
| 135 | 188 | func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error { | |
| 136 | 189 | if !validRef(ref) { | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "context" | 13 | "context" | ||
| 14 | "errors" | 14 | "errors" | ||
| 15 | "fmt" | 15 | "fmt" | ||
| 16 | + | "io" | |||
| 16 | "io/fs" | 17 | "io/fs" | ||
| 17 | "os" | 18 | "os" | ||
| 18 | "path/filepath" | 19 | "path/filepath" | ||
| @@ -89,7 +90,7 @@ func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown | |||||
| 89 | } | 90 | } | ||
| 90 | 91 | ||||
| 91 | // List returns the summaries of the steps that have one, by step number. | 92 | // List returns the summaries of the steps that have one, by step number. | ||
| 92 | - | func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) { | 93 | + | func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) { |
| 93 | if !validRef(ref) { | 94 | if !validRef(ref) { | ||
| 94 | return nil, false, nil | 95 | return nil, false, nil | ||
| 95 | } | 96 | } | ||
| @@ -118,19 +119,71 @@ func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain | |||||
| 118 | steps = steps[:limit] | 119 | steps = steps[:limit] | ||
| 119 | } | 120 | } | ||
| 120 | out := make([]domain.CIStepSummary, 0, len(steps)) | 121 | out := make([]domain.CIStepSummary, 0, len(steps)) | ||
| 122 | + | var read int64 | |||
| 123 | + | exhausted := false | |||
| 121 | for _, n := range steps { | 124 | for _, n := range steps { | ||
| 122 | - | b, err := os.ReadFile(s.path(ref, n)) | 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) | |||
| 123 | if errors.Is(err, fs.ErrNotExist) { | 145 | if errors.Is(err, fs.ErrNotExist) { | ||
| 124 | continue // removed meanwhile | 146 | continue // removed meanwhile | ||
| 125 | } | 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 | + | } | |||
| 126 | if err != nil { | 153 | if err != nil { | ||
| 127 | return nil, false, fmt.Errorf("read summary: %w", err) | 154 | return nil, false, fmt.Errorf("read summary: %w", err) | ||
| 128 | } | 155 | } | ||
| 156 | + | read += int64(len(b)) | |||
| 129 | out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)}) | 157 | out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)}) | ||
| 130 | } | 158 | } | ||
| 131 | return out, more, nil | 159 | return out, more, nil | ||
| 132 | } | 160 | } | ||
| 133 | 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 | + | ||||
| 134 | // RemoveJob deletes the summaries of one execution of a job. | 187 | // RemoveJob deletes the summaries of one execution of a job. | ||
| 135 | func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error { | 188 | func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error { | ||
| 136 | if !validRef(ref) { | 189 | if !validRef(ref) { | ||
M internal/adapters/cisummaries/cisummaries_test.go +68 -14
| @@ -23,7 +23,7 @@ func TestPutListReplaceAndRemove(t *testing.T) { | |||
| 23 | 23 | root := t.TempDir() | |
| 24 | 24 | s := cisummaries.New(root) | |
| 25 | 25 | ||
| 26 | - | got, more, err := s.List(ctx, ref, 20) | |
| 26 | + | got, more, err := s.List(ctx, ref, 20, 0) | |
| 27 | 27 | require.NoError(t, err) | |
| 28 | 28 | assert.Empty(t, got) | |
| 29 | 29 | assert.False(t, more) | |
| @@ -35,23 +35,23 @@ func TestPutListReplaceAndRemove(t *testing.T) { | |||
| 35 | 35 | require.NoError(t, err) | |
| 36 | 36 | assert.Equal(t, "two", string(b)) | |
| 37 | 37 | ||
| 38 | - | got, _, err = s.List(ctx, ref, 20) | |
| 38 | + | got, _, err = s.List(ctx, ref, 20, 0) | |
| 39 | 39 | require.NoError(t, err) | |
| 40 | 40 | assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name") | |
| 41 | 41 | ||
| 42 | 42 | // Replace, and an empty markdown removes. | |
| 43 | 43 | require.NoError(t, s.Put(ctx, ref, 2, "again")) | |
| 44 | - | got, _, _ = s.List(ctx, ref, 20) | |
| 44 | + | got, _, _ = s.List(ctx, ref, 20, 0) | |
| 45 | 45 | assert.Equal(t, "again", got[0].Markdown) | |
| 46 | 46 | require.NoError(t, s.Put(ctx, ref, 2, "")) | |
| 47 | 47 | require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine") | |
| 48 | - | got, _, _ = s.List(ctx, ref, 20) | |
| 48 | + | got, _, _ = s.List(ctx, ref, 20, 0) | |
| 49 | 49 | require.Len(t, got, 1) | |
| 50 | 50 | assert.EqualValues(t, 10, got[0].Step) | |
| 51 | 51 | ||
| 52 | 52 | // Another attempt and another job have their own. | |
| 53 | 53 | other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2} | |
| 54 | - | got, _, _ = s.List(ctx, other, 20) | |
| 54 | + | got, _, _ = s.List(ctx, other, 20, 0) | |
| 55 | 55 | assert.Empty(t, got) | |
| 56 | 56 | ||
| 57 | 57 | // A write leaves no temporary file behind. | |
| @@ -68,12 +68,12 @@ func TestListStopsAtTheLimit(t *testing.T) { | |||
| 68 | 68 | for i := int32(1); i <= 25; i++ { | |
| 69 | 69 | require.NoError(t, s.Put(ctx, ref, i, "s")) | |
| 70 | 70 | } | |
| 71 | - | got, more, err := s.List(ctx, ref, 20) | |
| 71 | + | got, more, err := s.List(ctx, ref, 20, 0) | |
| 72 | 72 | require.NoError(t, err) | |
| 73 | 73 | assert.Len(t, got, 20) | |
| 74 | 74 | assert.True(t, more) | |
| 75 | 75 | assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one") | |
| 76 | - | got, more, _ = s.List(ctx, ref, 25) | |
| 76 | + | got, more, _ = s.List(ctx, ref, 25, 0) | |
| 77 | 77 | assert.Len(t, got, 25) | |
| 78 | 78 | assert.False(t, more) | |
| 79 | 79 | } | |
| @@ -88,7 +88,7 @@ func TestListIgnoresStrayFiles(t *testing.T) { | |||
| 88 | 88 | for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} { | |
| 89 | 89 | require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600)) | |
| 90 | 90 | } | |
| 91 | - | got, _, err := s.List(ctx, ref, 20) | |
| 91 | + | got, _, err := s.List(ctx, ref, 20, 0) | |
| 92 | 92 | require.NoError(t, err) | |
| 93 | 93 | require.Len(t, got, 1) | |
| 94 | 94 | assert.Equal(t, "one", got[0].Markdown) | |
| @@ -110,15 +110,15 @@ func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) { | |||
| 110 | 110 | assert.ElementsMatch(t, []int64{7, 8}, ids) | |
| 111 | 111 | ||
| 112 | 112 | require.NoError(t, s.RemoveJob(ctx, ref)) | |
| 113 | - | got, _, _ := s.List(ctx, ref, 20) | |
| 113 | + | got, _, _ := s.List(ctx, ref, 20, 0) | |
| 114 | 114 | assert.Empty(t, got) | |
| 115 | - | got, _, _ = s.List(ctx, second, 20) | |
| 115 | + | got, _, _ = s.List(ctx, second, 20, 0) | |
| 116 | 116 | assert.Len(t, got, 1, "the other job of the run stays") | |
| 117 | 117 | ||
| 118 | 118 | require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99})) | |
| 119 | - | got, _, _ = s.List(ctx, second, 20) | |
| 119 | + | got, _, _ = s.List(ctx, second, 20, 0) | |
| 120 | 120 | assert.Empty(t, got) | |
| 121 | - | got, _, _ = s.List(ctx, third, 20) | |
| 121 | + | got, _, _ = s.List(ctx, third, 20, 0) | |
| 122 | 122 | assert.Len(t, got, 1) | |
| 123 | 123 | ids, _ = s.RunIDs(ctx) | |
| 124 | 124 | assert.Equal(t, []int64{8}, ids) | |
| @@ -136,7 +136,7 @@ func TestRefsCannotEscape(t *testing.T) { | |||
| 136 | 136 | {RunID: 7, JobID: "7-0-0", Attempt: 0}, | |
| 137 | 137 | } { | |
| 138 | 138 | assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound) | |
| 139 | - | got, _, err := s.List(ctx, bad, 20) | |
| 139 | + | got, _, err := s.List(ctx, bad, 20, 0) | |
| 140 | 140 | require.NoError(t, err) | |
| 141 | 141 | assert.Empty(t, got) | |
| 142 | 142 | } | |
| @@ -151,7 +151,61 @@ func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) { | |||
| 151 | 151 | s := cisummaries.New(t.TempDir()) | |
| 152 | 152 | big := strings.Repeat("x", domain.CIMaxSummaryBytes) | |
| 153 | 153 | require.NoError(t, s.Put(ctx, ref, 1, big)) | |
| 154 | - | got, _, err := s.List(ctx, ref, 20) | |
| 154 | + | got, _, err := s.List(ctx, ref, 20, 0) | |
| 155 | 155 | require.NoError(t, err) | |
| 156 | 156 | assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes) | |
| 157 | 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 | + | } | |
| @@ -23,7 +23,7 @@ func TestPutListReplaceAndRemove(t *testing.T) { | |||||
| 23 | root := t.TempDir() | 23 | root := t.TempDir() | ||
| 24 | s := cisummaries.New(root) | 24 | s := cisummaries.New(root) | ||
| 25 | 25 | ||||
| 26 | - | got, more, err := s.List(ctx, ref, 20) | 26 | + | got, more, err := s.List(ctx, ref, 20, 0) |
| 27 | require.NoError(t, err) | 27 | require.NoError(t, err) | ||
| 28 | assert.Empty(t, got) | 28 | assert.Empty(t, got) | ||
| 29 | assert.False(t, more) | 29 | assert.False(t, more) | ||
| @@ -35,23 +35,23 @@ func TestPutListReplaceAndRemove(t *testing.T) { | |||||
| 35 | require.NoError(t, err) | 35 | require.NoError(t, err) | ||
| 36 | assert.Equal(t, "two", string(b)) | 36 | assert.Equal(t, "two", string(b)) | ||
| 37 | 37 | ||||
| 38 | - | got, _, err = s.List(ctx, ref, 20) | 38 | + | got, _, err = s.List(ctx, ref, 20, 0) |
| 39 | require.NoError(t, err) | 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") | 40 | assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name") | ||
| 41 | 41 | ||||
| 42 | // Replace, and an empty markdown removes. | 42 | // Replace, and an empty markdown removes. | ||
| 43 | require.NoError(t, s.Put(ctx, ref, 2, "again")) | 43 | require.NoError(t, s.Put(ctx, ref, 2, "again")) | ||
| 44 | - | got, _, _ = s.List(ctx, ref, 20) | 44 | + | got, _, _ = s.List(ctx, ref, 20, 0) |
| 45 | assert.Equal(t, "again", got[0].Markdown) | 45 | assert.Equal(t, "again", got[0].Markdown) | ||
| 46 | require.NoError(t, s.Put(ctx, ref, 2, "")) | 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") | 47 | require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine") | ||
| 48 | - | got, _, _ = s.List(ctx, ref, 20) | 48 | + | got, _, _ = s.List(ctx, ref, 20, 0) |
| 49 | require.Len(t, got, 1) | 49 | require.Len(t, got, 1) | ||
| 50 | assert.EqualValues(t, 10, got[0].Step) | 50 | assert.EqualValues(t, 10, got[0].Step) | ||
| 51 | 51 | ||||
| 52 | // Another attempt and another job have their own. | 52 | // Another attempt and another job have their own. | ||
| 53 | other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2} | 53 | other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2} | ||
| 54 | - | got, _, _ = s.List(ctx, other, 20) | 54 | + | got, _, _ = s.List(ctx, other, 20, 0) |
| 55 | assert.Empty(t, got) | 55 | assert.Empty(t, got) | ||
| 56 | 56 | ||||
| 57 | // A write leaves no temporary file behind. | 57 | // A write leaves no temporary file behind. | ||
| @@ -68,12 +68,12 @@ func TestListStopsAtTheLimit(t *testing.T) { | |||||
| 68 | for i := int32(1); i <= 25; i++ { | 68 | for i := int32(1); i <= 25; i++ { | ||
| 69 | require.NoError(t, s.Put(ctx, ref, i, "s")) | 69 | require.NoError(t, s.Put(ctx, ref, i, "s")) | ||
| 70 | } | 70 | } | ||
| 71 | - | got, more, err := s.List(ctx, ref, 20) | 71 | + | got, more, err := s.List(ctx, ref, 20, 0) |
| 72 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 73 | assert.Len(t, got, 20) | 73 | assert.Len(t, got, 20) | ||
| 74 | assert.True(t, more) | 74 | assert.True(t, more) | ||
| 75 | assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one") | 75 | assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one") | ||
| 76 | - | got, more, _ = s.List(ctx, ref, 25) | 76 | + | got, more, _ = s.List(ctx, ref, 25, 0) |
| 77 | assert.Len(t, got, 25) | 77 | assert.Len(t, got, 25) | ||
| 78 | assert.False(t, more) | 78 | assert.False(t, more) | ||
| 79 | } | 79 | } | ||
| @@ -88,7 +88,7 @@ func TestListIgnoresStrayFiles(t *testing.T) { | |||||
| 88 | for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} { | 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)) | 89 | require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600)) | ||
| 90 | } | 90 | } | ||
| 91 | - | got, _, err := s.List(ctx, ref, 20) | 91 | + | got, _, err := s.List(ctx, ref, 20, 0) |
| 92 | require.NoError(t, err) | 92 | require.NoError(t, err) | ||
| 93 | require.Len(t, got, 1) | 93 | require.Len(t, got, 1) | ||
| 94 | assert.Equal(t, "one", got[0].Markdown) | 94 | assert.Equal(t, "one", got[0].Markdown) | ||
| @@ -110,15 +110,15 @@ func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) { | |||||
| 110 | assert.ElementsMatch(t, []int64{7, 8}, ids) | 110 | assert.ElementsMatch(t, []int64{7, 8}, ids) | ||
| 111 | 111 | ||||
| 112 | require.NoError(t, s.RemoveJob(ctx, ref)) | 112 | require.NoError(t, s.RemoveJob(ctx, ref)) | ||
| 113 | - | got, _, _ := s.List(ctx, ref, 20) | 113 | + | got, _, _ := s.List(ctx, ref, 20, 0) |
| 114 | assert.Empty(t, got) | 114 | assert.Empty(t, got) | ||
| 115 | - | got, _, _ = s.List(ctx, second, 20) | 115 | + | got, _, _ = s.List(ctx, second, 20, 0) |
| 116 | assert.Len(t, got, 1, "the other job of the run stays") | 116 | assert.Len(t, got, 1, "the other job of the run stays") | ||
| 117 | 117 | ||||
| 118 | require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99})) | 118 | require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99})) | ||
| 119 | - | got, _, _ = s.List(ctx, second, 20) | 119 | + | got, _, _ = s.List(ctx, second, 20, 0) |
| 120 | assert.Empty(t, got) | 120 | assert.Empty(t, got) | ||
| 121 | - | got, _, _ = s.List(ctx, third, 20) | 121 | + | got, _, _ = s.List(ctx, third, 20, 0) |
| 122 | assert.Len(t, got, 1) | 122 | assert.Len(t, got, 1) | ||
| 123 | ids, _ = s.RunIDs(ctx) | 123 | ids, _ = s.RunIDs(ctx) | ||
| 124 | assert.Equal(t, []int64{8}, ids) | 124 | assert.Equal(t, []int64{8}, ids) | ||
| @@ -136,7 +136,7 @@ func TestRefsCannotEscape(t *testing.T) { | |||||
| 136 | {RunID: 7, JobID: "7-0-0", Attempt: 0}, | 136 | {RunID: 7, JobID: "7-0-0", Attempt: 0}, | ||
| 137 | } { | 137 | } { | ||
| 138 | assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound) | 138 | assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound) | ||
| 139 | - | got, _, err := s.List(ctx, bad, 20) | 139 | + | got, _, err := s.List(ctx, bad, 20, 0) |
| 140 | require.NoError(t, err) | 140 | require.NoError(t, err) | ||
| 141 | assert.Empty(t, got) | 141 | assert.Empty(t, got) | ||
| 142 | } | 142 | } | ||
| @@ -151,7 +151,61 @@ func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) { | |||||
| 151 | s := cisummaries.New(t.TempDir()) | 151 | s := cisummaries.New(t.TempDir()) | ||
| 152 | big := strings.Repeat("x", domain.CIMaxSummaryBytes) | 152 | big := strings.Repeat("x", domain.CIMaxSummaryBytes) | ||
| 153 | require.NoError(t, s.Put(ctx, ref, 1, big)) | 153 | require.NoError(t, s.Put(ctx, ref, 1, big)) | ||
| 154 | - | got, _, err := s.List(ctx, ref, 20) | 154 | + | got, _, err := s.List(ctx, ref, 20, 0) |
| 155 | require.NoError(t, err) | 155 | require.NoError(t, err) | ||
| 156 | assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes) | 156 | assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes) | ||
| 157 | } | 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/rpc/ci.go +1 -1
| @@ -367,7 +367,7 @@ func (s *runService) GetStepSummaries(ctx context.Context, req *connect.Request[ | |||
| 367 | 367 | if err != nil { | |
| 368 | 368 | return nil, ciError(ctx, err) | |
| 369 | 369 | } | |
| 370 | - | res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt()) | |
| 370 | + | res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt(), 0) | |
| 371 | 371 | if err != nil { | |
| 372 | 372 | return nil, ciError(ctx, err) | |
| 373 | 373 | } | |
| @@ -367,7 +367,7 @@ func (s *runService) GetStepSummaries(ctx context.Context, req *connect.Request[ | |||||
| 367 | if err != nil { | 367 | if err != nil { | ||
| 368 | return nil, ciError(ctx, err) | 368 | return nil, ciError(ctx, err) | ||
| 369 | } | 369 | } | ||
| 370 | - | res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt()) | 370 | + | res, err := s.runs.GetStepSummaries(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt(), 0) |
| 371 | if err != nil { | 371 | if err != nil { | ||
| 372 | return nil, ciError(ctx, err) | 372 | return nil, ciError(ctx, err) | ||
| 373 | } | 373 | } | ||
M internal/adapters/rpc/ci_test.go +1 -1
| @@ -175,7 +175,7 @@ func TestRunServiceAnnotationsAndSummaries(t *testing.T) { | |||
| 175 | 175 | Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}}, | |
| 176 | 176 | Truncated: true, | |
| 177 | 177 | }, nil).Once() | |
| 178 | - | runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0)).Return(ports.SummaryList{ | |
| 178 | + | runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0), int64(0)).Return(ports.SummaryList{ | |
| 179 | 179 | Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true, | |
| 180 | 180 | }, nil).Once() | |
| 181 | 181 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once() | |
| @@ -175,7 +175,7 @@ func TestRunServiceAnnotationsAndSummaries(t *testing.T) { | |||||
| 175 | Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}}, | 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, | 176 | Truncated: true, | ||
| 177 | }, nil).Once() | 177 | }, nil).Once() | ||
| 178 | - | runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0)).Return(ports.SummaryList{ | 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, | 179 | Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true, | ||
| 180 | }, nil).Once() | 180 | }, nil).Once() | ||
| 181 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once() | 181 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once() | ||
M internal/adapters/web/actions.templ +5 -2
| @@ -62,7 +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) | |
| 65 | + | @RunSummaries(p.Summaries, p.SummariesMore) | |
| 66 | 66 | } | |
| 67 | 67 | } | |
| 68 | 68 | ||
| @@ -181,7 +181,7 @@ templ JobAnnotations(anns []AnnotationView, more bool) { | |||
| 181 | 181 | // RunSummaries is the job summaries of a run, one block per step that wrote | |
| 182 | 182 | // one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs | |
| 183 | 183 | // filtered, images shown as their alt text. | |
| 184 | - | templ RunSummaries(sums []JobSummaries) { | |
| 184 | + | templ RunSummaries(sums []JobSummaries, more bool) { | |
| 185 | 185 | for _, j := range sums { | |
| 186 | 186 | <section class="summaries" aria-label={ "Summary of " + j.Job }> | |
| 187 | 187 | <h3> | |
| @@ -207,6 +207,9 @@ templ RunSummaries(sums []JobSummaries) { | |||
| 207 | 207 | } | |
| 208 | 208 | </section> | |
| 209 | 209 | } | |
| 210 | + | if more { | |
| 211 | + | <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p> | |
| 212 | + | } | |
| 210 | 213 | } | |
| 211 | 214 | ||
| 212 | 215 | templ runRail(p RunPage) { | |
| @@ -62,7 +62,7 @@ templ RunDetail(l Layout, p RunPage) { | |||||
| 62 | <div id="run-body"> | 62 | <div id="run-body"> | ||
| 63 | @RunBody(p) | 63 | @RunBody(p) | ||
| 64 | </div> | 64 | </div> | ||
| 65 | - | @RunSummaries(p.Summaries) | 65 | + | @RunSummaries(p.Summaries, p.SummariesMore) |
| 66 | } | 66 | } | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| @@ -181,7 +181,7 @@ templ JobAnnotations(anns []AnnotationView, more bool) { | |||||
| 181 | // RunSummaries is the job summaries of a run, one block per step that wrote | 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 | 182 | // one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs | ||
| 183 | // filtered, images shown as their alt text. | 183 | // filtered, images shown as their alt text. | ||
| 184 | - | templ RunSummaries(sums []JobSummaries) { | 184 | + | templ RunSummaries(sums []JobSummaries, more bool) { |
| 185 | for _, j := range sums { | 185 | for _, j := range sums { | ||
| 186 | <section class="summaries" aria-label={ "Summary of " + j.Job }> | 186 | <section class="summaries" aria-label={ "Summary of " + j.Job }> | ||
| 187 | <h3> | 187 | <h3> | ||
| @@ -207,6 +207,9 @@ templ RunSummaries(sums []JobSummaries) { | |||||
| 207 | } | 207 | } | ||
| 208 | </section> | 208 | </section> | ||
| 209 | } | 209 | } | ||
| 210 | + | if more { | |||
| 211 | + | <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p> | |||
| 212 | + | } | |||
| 210 | } | 213 | } | ||
| 211 | 214 | ||||
| 212 | templ runRail(p RunPage) { | 215 | templ runRail(p RunPage) { | ||
M internal/adapters/web/actions_pages.go +4 -2
| @@ -179,7 +179,9 @@ type RunPage struct { | |||
| 179 | 179 | AnnotationsMore bool | |
| 180 | 180 | // Summaries are the job summaries, rendered; only the full page has them. | |
| 181 | 181 | Summaries []JobSummaries | |
| 182 | - | Active bool | |
| 182 | + | // SummariesMore says that some summaries are not shown (the page budget). | |
| 183 | + | SummariesMore bool | |
| 184 | + | Active bool | |
| 183 | 185 | // detail is what the page was built from. | |
| 184 | 186 | detail ports.RunDetail | |
| 185 | 187 | // Forms are the owner's actions; nil/empty when the visitor cannot. | |
| @@ -294,7 +296,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||
| 294 | 296 | } | |
| 295 | 297 | // The summaries are part of the page, not of the live body: the page | |
| 296 | 298 | // fetches itself again when the run completes (runBody), and they show then. | |
| 297 | - | if p.Summaries, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest); err != nil { | |
| 299 | + | if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil { | |
| 298 | 300 | return nil, 0, err | |
| 299 | 301 | } | |
| 300 | 302 | l := s.repoLayout(ctx, d, "actions") | |
| @@ -179,7 +179,9 @@ type RunPage struct { | |||||
| 179 | AnnotationsMore bool | 179 | AnnotationsMore bool | ||
| 180 | // Summaries are the job summaries, rendered; only the full page has them. | 180 | // Summaries are the job summaries, rendered; only the full page has them. | ||
| 181 | Summaries []JobSummaries | 181 | Summaries []JobSummaries | ||
| 182 | - | Active bool | 182 | + | // SummariesMore says that some summaries are not shown (the page budget). |
| 183 | + | SummariesMore bool | |||
| 184 | + | Active bool | |||
| 183 | // detail is what the page was built from. | 185 | // detail is what the page was built from. | ||
| 184 | detail ports.RunDetail | 186 | detail ports.RunDetail | ||
| 185 | // Forms are the owner's actions; nil/empty when the visitor cannot. | 187 | // Forms are the owner's actions; nil/empty when the visitor cannot. | ||
| @@ -294,7 +296,7 @@ func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int | |||||
| 294 | } | 296 | } | ||
| 295 | // The summaries are part of the page, not of the live body: the page | 297 | // The summaries are part of the page, not of the live body: the page | ||
| 296 | // fetches itself again when the run completes (runBody), and they show then. | 298 | // fetches itself again when the run completes (runBody), and they show then. | ||
| 297 | - | if p.Summaries, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest); err != nil { | 299 | + | if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil { |
| 298 | return nil, 0, err | 300 | return nil, 0, err | ||
| 299 | } | 301 | } | ||
| 300 | l := s.repoLayout(ctx, d, "actions") | 302 | l := s.repoLayout(ctx, d, "actions") | ||
M internal/adapters/web/actions_results.go +34 -10
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries | |
| 23 | 23 | // together; the ones past it are not shown, with a note. | |
| 24 | - | const maxSummaryPageBytes = 4 << 20 | |
| 24 | + | const maxSummaryPageBytes int64 = 4 << 20 | |
| 25 | 25 | ||
| 26 | 26 | // AnnotationView is an annotation as shown. | |
| 27 | 27 | type AnnotationView struct { | |
| @@ -156,17 +156,21 @@ type JobSummaries struct { | |||
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | 158 | // jobSummaries reads and renders the summaries of the jobs of a run, in the | |
| 159 | - | // order of the jobs, within maxSummaryPageBytes of Markdown. | |
| 160 | - | func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool) ([]JobSummaries, error) { | |
| 161 | - | budget := maxSummaryPageBytes | |
| 162 | - | var out []JobSummaries | |
| 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) { | |
| 163 | 164 | for _, j := range jobs { | |
| 164 | 165 | if j.Phase != domain.CIPhaseRunning && !j.Ended() { | |
| 165 | 166 | continue // no step has run | |
| 166 | 167 | } | |
| 167 | - | res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt) | |
| 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) | |
| 168 | 172 | if err != nil { | |
| 169 | - | return nil, err | |
| 173 | + | return nil, false, err | |
| 170 | 174 | } | |
| 171 | 175 | if len(res.Summaries) == 0 { | |
| 172 | 176 | continue | |
| @@ -179,12 +183,14 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun | |||
| 179 | 183 | name = st.name | |
| 180 | 184 | } | |
| 181 | 185 | v := SummaryView{Step: name} | |
| 182 | - | if len(sm.Markdown) > budget { | |
| 186 | + | if sm.Omitted { | |
| 183 | 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 | |
| 184 | 190 | js.Steps = append(js.Steps, v) | |
| 185 | 191 | continue | |
| 186 | 192 | } | |
| 187 | - | budget -= len(sm.Markdown) | |
| 193 | + | budget -= int64(len(sm.Markdown)) | |
| 188 | 194 | prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-" | |
| 189 | 195 | h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix) | |
| 190 | 196 | if err != nil { | |
| @@ -196,7 +202,25 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun | |||
| 196 | 202 | } | |
| 197 | 203 | out = append(out, js) | |
| 198 | 204 | } | |
| 199 | - | return out, nil | |
| 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 | |
| 200 | 224 | } | |
| 201 | 225 | ||
| 202 | 226 | // runAnnotations reads the annotations of a run attempt and builds their views. | |
| @@ -21,7 +21,7 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries | 22 | // maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries | ||
| 23 | // together; the ones past it are not shown, with a note. | 23 | // together; the ones past it are not shown, with a note. | ||
| 24 | - | const maxSummaryPageBytes = 4 << 20 | 24 | + | const maxSummaryPageBytes int64 = 4 << 20 |
| 25 | 25 | ||||
| 26 | // AnnotationView is an annotation as shown. | 26 | // AnnotationView is an annotation as shown. | ||
| 27 | type AnnotationView struct { | 27 | type AnnotationView struct { | ||
| @@ -156,17 +156,21 @@ type JobSummaries struct { | |||||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | // jobSummaries reads and renders the summaries of the jobs of a run, in the | 158 | // jobSummaries reads and renders the summaries of the jobs of a run, in the | ||
| 159 | - | // order of the jobs, within maxSummaryPageBytes of Markdown. | 159 | + | // order of the jobs, within budget bytes of Markdown. The budget goes down as |
| 160 | - | func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool) ([]JobSummaries, error) { | 160 | + | // summaries are read and is handed to the use case, which sizes the files |
| 161 | - | budget := maxSummaryPageBytes | 161 | + | // before it reads them: a page view reads at most budget bytes. Once it is used |
| 162 | - | var out []JobSummaries | 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) { | |||
| 163 | for _, j := range jobs { | 164 | for _, j := range jobs { | ||
| 164 | if j.Phase != domain.CIPhaseRunning && !j.Ended() { | 165 | if j.Phase != domain.CIPhaseRunning && !j.Ended() { | ||
| 165 | continue // no step has run | 166 | continue // no step has run | ||
| 166 | } | 167 | } | ||
| 167 | - | res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt) | 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) | |||
| 168 | if err != nil { | 172 | if err != nil { | ||
| 169 | - | return nil, err | 173 | + | return nil, false, err |
| 170 | } | 174 | } | ||
| 171 | if len(res.Summaries) == 0 { | 175 | if len(res.Summaries) == 0 { | ||
| 172 | continue | 176 | continue | ||
| @@ -179,12 +183,14 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun | |||||
| 179 | name = st.name | 183 | name = st.name | ||
| 180 | } | 184 | } | ||
| 181 | v := SummaryView{Step: name} | 185 | v := SummaryView{Step: name} | ||
| 182 | - | if len(sm.Markdown) > budget { | 186 | + | if sm.Omitted { |
| 183 | v.Note = "this summary is not shown: the summaries of the page are over " + humanSize(maxSummaryPageBytes) + "." | 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 | |||
| 184 | js.Steps = append(js.Steps, v) | 190 | js.Steps = append(js.Steps, v) | ||
| 185 | continue | 191 | continue | ||
| 186 | } | 192 | } | ||
| 187 | - | budget -= len(sm.Markdown) | 193 | + | budget -= int64(len(sm.Markdown)) |
| 188 | prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-" | 194 | prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-" | ||
| 189 | h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix) | 195 | h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix) | ||
| 190 | if err != nil { | 196 | if err != nil { | ||
| @@ -196,7 +202,25 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun | |||||
| 196 | } | 202 | } | ||
| 197 | out = append(out, js) | 203 | out = append(out, js) | ||
| 198 | } | 204 | } | ||
| 199 | - | return out, nil | 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 | |||
| 200 | } | 224 | } | ||
| 201 | 225 | ||||
| 202 | // runAnnotations reads the annotations of a run attempt and builds their views. | 226 | // runAnnotations reads the annotations of a run attempt and builds their views. | ||
M internal/adapters/web/actions_results_test.go +108 -1
| @@ -48,7 +48,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) { | |||
| 48 | 48 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | |
| 49 | 49 | }, nil) | |
| 50 | 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)).Return(ports.SummaryList{ | |
| 51 | + | runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, jobID, int32(1), maxSummaryPageBytes).Return(ports.SummaryList{ | |
| 52 | 52 | Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true, | |
| 53 | 53 | }, nil) | |
| 54 | 54 | browse := mocks.NewBrowseService(t) | |
| @@ -128,3 +128,110 @@ func TestRepoPath(t *testing.T) { | |||
| 128 | 128 | assert.False(t, repoPath(bad), "%q", bad) | |
| 129 | 129 | } | |
| 130 | 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 | + | } | |
| @@ -48,7 +48,7 @@ func TestSummariesAreRenderedSafely(t *testing.T) { | |||||
| 48 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | 48 | Jobs: []ports.JobView{job}, LatestAttempt: 1, | ||
| 49 | }, nil) | 49 | }, nil) | ||
| 50 | runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, 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)).Return(ports.SummaryList{ | 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, | 52 | Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true, | ||
| 53 | }, nil) | 53 | }, nil) | ||
| 54 | browse := mocks.NewBrowseService(t) | 54 | browse := mocks.NewBrowseService(t) | ||
| @@ -128,3 +128,110 @@ func TestRepoPath(t *testing.T) { | |||||
| 128 | assert.False(t, repoPath(bad), "%q", bad) | 128 | assert.False(t, repoPath(bad), "%q", bad) | ||
| 129 | } | 129 | } | ||
| 130 | } | 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 +355 -336 generated file: collapsed
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) { | |
| @@ -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 | // The watch runs in a goroutine and hands each event to this one through | 139 | // The watch runs in a goroutine and hands each event to this one through | ||
| 124 | // an unbuffered channel: a visitor that reads slowly holds the watch in | 140 | // an unbuffered channel: a visitor that reads slowly holds the watch in | ||
| 125 | // its send, falls behind the buffer of the use case, and gets a gap. | 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 | return | 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 | handle := func(m recvd) bool { | 222 | handle := func(m recvd) bool { | ||
| 207 | if m.err != nil { | 223 | if m.err != nil { | ||
| 208 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | 224 | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | ||
M internal/adapters/web/fragments.go +7 -5
| @@ -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,11 +502,7 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |||
| 496 | 502 | if err != nil { | |
| 497 | 503 | return nil, err | |
| 498 | 504 | } | |
| 499 | - | run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, view.RunID, 0) | |
| 500 | - | if err != nil { | |
| 501 | - | return nil, err | |
| 502 | - | } | |
| 503 | - | anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, run.Run.Sha, view, 0) | |
| 505 | + | anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0) | |
| 504 | 506 | if err != nil { | |
| 505 | 507 | return nil, err | |
| 506 | 508 | } | |
| @@ -26,6 +26,9 @@ type liveStream struct { | |||||
| 26 | head string // commits: the commit listed first | 26 | head string // commits: the commit listed first | ||
| 27 | n string // mr: the number of the merge request shown; run: the run id | 27 | n string // mr: the number of the merge request shown; run: the run id | ||
| 28 | job string // job: the id of the job shown | 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 | // renderFragment renders a component to the data of an event. | 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 | // jobState tells the job page that its state or its steps changed: the page | 481 | // jobState tells the job page that its state or its steps changed: the page | ||
| 479 | // reloads the part of the log after the last line it holds (ermite.js). | 482 | // reloads the part of the log after the last line it holds (ermite.js). | ||
| 480 | func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | 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 | d, err := l.s.loadRepo(ctx, l.repo) | 487 | d, err := l.s.loadRepo(ctx, l.repo) | ||
| 482 | if err != nil { | 488 | if err != nil { | ||
| 483 | return nil, err | 489 | return nil, err | ||
| @@ -496,11 +502,7 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) { | |||||
| 496 | if err != nil { | 502 | if err != nil { | ||
| 497 | return nil, err | 503 | return nil, err | ||
| 498 | } | 504 | } | ||
| 499 | - | run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, view.RunID, 0) | 505 | + | anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0) |
| 500 | - | if err != nil { | |||
| 501 | - | return nil, err | |||
| 502 | - | } | |||
| 503 | - | anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, run.Run.Sha, view, 0) | |||
| 504 | if err != nil { | 506 | if err != nil { | ||
| 505 | return nil, err | 507 | return nil, err | ||
| 506 | } | 508 | } | ||
M internal/domain/ci_annotation.go +3 -0
| @@ -57,6 +57,9 @@ type CIAnnotation struct { | |||
| 57 | 57 | type CIStepSummary struct { | |
| 58 | 58 | Step int32 | |
| 59 | 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 | |
| 60 | 63 | } | |
| 61 | 64 | ||
| 62 | 65 | // CheckAnnotations validates the annotations of a request: the limits of the | |
| @@ -57,6 +57,9 @@ type CIAnnotation struct { | |||||
| 57 | type CIStepSummary struct { | 57 | type CIStepSummary struct { | ||
| 58 | Step int32 | 58 | Step int32 | ||
| 59 | Markdown string | 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 | |||
| 60 | } | 63 | } | ||
| 61 | 64 | ||||
| 62 | // CheckAnnotations validates the annotations of a request: the limits of the | 65 | // CheckAnnotations validates the annotations of a request: the limits of the | ||
M internal/ports/ci.go +10 -4
| @@ -218,8 +218,12 @@ type CISummaryStore interface { | |||
| 218 | 218 | // appears whole or not at all. An empty markdown removes the summary. | |
| 219 | 219 | Put(ctx context.Context, ref CILogRef, step int32, markdown string) error | |
| 220 | 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. | |
| 222 | - | List(ctx context.Context, ref CILogRef, limit int) (summaries []domain.CIStepSummary, more bool, err error) | |
| 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) | |
| 223 | 227 | // RemoveJob deletes the summaries of one execution of a job. | |
| 224 | 228 | RemoveJob(ctx context.Context, ref CILogRef) error | |
| 225 | 229 | // RemoveRuns deletes the summaries of the runs. | |
| @@ -531,8 +535,10 @@ type RunService interface { | |||
| 531 | 535 | // attempt in which it executed. | |
| 532 | 536 | ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error) | |
| 533 | 537 | // GetStepSummaries returns the summaries of a job at an attempt (0 for the | |
| 534 | - | // latest), by step number. | |
| 535 | - | GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (SummaryList, error) | |
| 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) | |
| 536 | 542 | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | |
| 537 | 543 | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | |
| 538 | 544 | } | |
| @@ -218,8 +218,12 @@ type CISummaryStore interface { | |||||
| 218 | // appears whole or not at all. An empty markdown removes the summary. | 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 | 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, | 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. | 221 | + | // at most limit; more is true when there are others. With maxBytes above |
| 222 | - | List(ctx context.Context, ref CILogRef, limit int) (summaries []domain.CIStepSummary, more bool, err error) | 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) | |||
| 223 | // RemoveJob deletes the summaries of one execution of a job. | 227 | // RemoveJob deletes the summaries of one execution of a job. | ||
| 224 | RemoveJob(ctx context.Context, ref CILogRef) error | 228 | RemoveJob(ctx context.Context, ref CILogRef) error | ||
| 225 | // RemoveRuns deletes the summaries of the runs. | 229 | // RemoveRuns deletes the summaries of the runs. | ||
| @@ -531,8 +535,10 @@ type RunService interface { | |||||
| 531 | // attempt in which it executed. | 535 | // attempt in which it executed. | ||
| 532 | ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error) | 536 | ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error) | ||
| 533 | // GetStepSummaries returns the summaries of a job at an attempt (0 for the | 537 | // GetStepSummaries returns the summaries of a job at an attempt (0 for the | ||
| 534 | - | // latest), by step number. | 538 | + | // latest), by step number. maxBytes above 0 bounds the Markdown read: the |
| 535 | - | GetStepSummaries(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (SummaryList, error) | 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) | |||
| 536 | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | 542 | ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error) | ||
| 537 | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | 543 | RemoveRunner(ctx context.Context, p domain.Principal, id string) error | ||
| 538 | } | 544 | } | ||
M internal/ports/mocks/CISummaryStore.go +20 -14
| @@ -49,8 +49,8 @@ func (_m *CISummaryStore) EXPECT() *CISummaryStore_Expecter { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | // List provides a mock function for the type CISummaryStore | |
| 52 | - | func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) { | |
| 53 | - | ret := _mock.Called(ctx, ref, limit) | |
| 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 | 54 | ||
| 55 | 55 | if len(ret) == 0 { | |
| 56 | 56 | panic("no return value specified for List") | |
| @@ -59,23 +59,23 @@ func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit | |||
| 59 | 59 | var r0 []domain.CIStepSummary | |
| 60 | 60 | var r1 bool | |
| 61 | 61 | var r2 error | |
| 62 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) ([]domain.CIStepSummary, bool, error)); ok { | |
| 63 | - | return returnFunc(ctx, ref, limit) | |
| 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 | 64 | } | |
| 65 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) []domain.CIStepSummary); ok { | |
| 66 | - | r0 = returnFunc(ctx, ref, limit) | |
| 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 | 67 | } else { | |
| 68 | 68 | if ret.Get(0) != nil { | |
| 69 | 69 | r0 = ret.Get(0).([]domain.CIStepSummary) | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int) bool); ok { | |
| 73 | - | r1 = returnFunc(ctx, ref, limit) | |
| 72 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int, int64) bool); ok { | |
| 73 | + | r1 = returnFunc(ctx, ref, limit, maxBytes) | |
| 74 | 74 | } else { | |
| 75 | 75 | r1 = ret.Get(1).(bool) | |
| 76 | 76 | } | |
| 77 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int) error); ok { | |
| 78 | - | r2 = returnFunc(ctx, ref, limit) | |
| 77 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int, int64) error); ok { | |
| 78 | + | r2 = returnFunc(ctx, ref, limit, maxBytes) | |
| 79 | 79 | } else { | |
| 80 | 80 | r2 = ret.Error(2) | |
| 81 | 81 | } | |
| @@ -91,11 +91,12 @@ type CISummaryStore_List_Call struct { | |||
| 91 | 91 | // - ctx context.Context | |
| 92 | 92 | // - ref ports.CILogRef | |
| 93 | 93 | // - limit int | |
| 94 | - | func (_e *CISummaryStore_Expecter) List(ctx any, ref any, limit any) *CISummaryStore_List_Call { | |
| 95 | - | return &CISummaryStore_List_Call{Call: _e.mock.On("List", ctx, ref, limit)} | |
| 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)} | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | - | func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int)) *CISummaryStore_List_Call { | |
| 99 | + | func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64)) *CISummaryStore_List_Call { | |
| 99 | 100 | _c.Call.Run(func(args mock.Arguments) { | |
| 100 | 101 | var arg0 context.Context | |
| 101 | 102 | if args[0] != nil { | |
| @@ -109,10 +110,15 @@ func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports. | |||
| 109 | 110 | if args[2] != nil { | |
| 110 | 111 | arg2 = args[2].(int) | |
| 111 | 112 | } | |
| 113 | + | var arg3 int64 | |
| 114 | + | if args[3] != nil { | |
| 115 | + | arg3 = args[3].(int64) | |
| 116 | + | } | |
| 112 | 117 | run( | |
| 113 | 118 | arg0, | |
| 114 | 119 | arg1, | |
| 115 | 120 | arg2, | |
| 121 | + | arg3, | |
| 116 | 122 | ) | |
| 117 | 123 | }) | |
| 118 | 124 | return _c | |
| @@ -123,7 +129,7 @@ func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, mor | |||
| 123 | 129 | return _c | |
| 124 | 130 | } | |
| 125 | 131 | ||
| 126 | - | func (_c *CISummaryStore_List_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error)) *CISummaryStore_List_Call { | |
| 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 { | |
| 127 | 133 | _c.Call.Return(run) | |
| 128 | 134 | return _c | |
| 129 | 135 | } | |
| @@ -49,8 +49,8 @@ func (_m *CISummaryStore) EXPECT() *CISummaryStore_Expecter { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | // List provides a mock function for the type CISummaryStore | 51 | // List provides a mock function for the type CISummaryStore | ||
| 52 | - | func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error) { | 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) | 53 | + | ret := _mock.Called(ctx, ref, limit, maxBytes) |
| 54 | 54 | ||||
| 55 | if len(ret) == 0 { | 55 | if len(ret) == 0 { | ||
| 56 | panic("no return value specified for List") | 56 | panic("no return value specified for List") | ||
| @@ -59,23 +59,23 @@ func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit | |||||
| 59 | var r0 []domain.CIStepSummary | 59 | var r0 []domain.CIStepSummary | ||
| 60 | var r1 bool | 60 | var r1 bool | ||
| 61 | var r2 error | 61 | var r2 error | ||
| 62 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) ([]domain.CIStepSummary, bool, error)); ok { | 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) | 63 | + | return returnFunc(ctx, ref, limit, maxBytes) |
| 64 | } | 64 | } | ||
| 65 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int) []domain.CIStepSummary); ok { | 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int, int64) []domain.CIStepSummary); ok { |
| 66 | - | r0 = returnFunc(ctx, ref, limit) | 66 | + | r0 = returnFunc(ctx, ref, limit, maxBytes) |
| 67 | } else { | 67 | } else { | ||
| 68 | if ret.Get(0) != nil { | 68 | if ret.Get(0) != nil { | ||
| 69 | r0 = ret.Get(0).([]domain.CIStepSummary) | 69 | r0 = ret.Get(0).([]domain.CIStepSummary) | ||
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int) bool); ok { | 72 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int, int64) bool); ok { |
| 73 | - | r1 = returnFunc(ctx, ref, limit) | 73 | + | r1 = returnFunc(ctx, ref, limit, maxBytes) |
| 74 | } else { | 74 | } else { | ||
| 75 | r1 = ret.Get(1).(bool) | 75 | r1 = ret.Get(1).(bool) | ||
| 76 | } | 76 | } | ||
| 77 | - | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int) error); ok { | 77 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int, int64) error); ok { |
| 78 | - | r2 = returnFunc(ctx, ref, limit) | 78 | + | r2 = returnFunc(ctx, ref, limit, maxBytes) |
| 79 | } else { | 79 | } else { | ||
| 80 | r2 = ret.Error(2) | 80 | r2 = ret.Error(2) | ||
| 81 | } | 81 | } | ||
| @@ -91,11 +91,12 @@ type CISummaryStore_List_Call struct { | |||||
| 91 | // - ctx context.Context | 91 | // - ctx context.Context | ||
| 92 | // - ref ports.CILogRef | 92 | // - ref ports.CILogRef | ||
| 93 | // - limit int | 93 | // - limit int | ||
| 94 | - | func (_e *CISummaryStore_Expecter) List(ctx any, ref any, limit any) *CISummaryStore_List_Call { | 94 | + | // - maxBytes int64 |
| 95 | - | return &CISummaryStore_List_Call{Call: _e.mock.On("List", ctx, ref, limit)} | 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)} | |||
| 96 | } | 97 | } | ||
| 97 | 98 | ||||
| 98 | - | func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int)) *CISummaryStore_List_Call { | 99 | + | func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.CILogRef, limit int, maxBytes int64)) *CISummaryStore_List_Call { |
| 99 | _c.Call.Run(func(args mock.Arguments) { | 100 | _c.Call.Run(func(args mock.Arguments) { | ||
| 100 | var arg0 context.Context | 101 | var arg0 context.Context | ||
| 101 | if args[0] != nil { | 102 | if args[0] != nil { | ||
| @@ -109,10 +110,15 @@ func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports. | |||||
| 109 | if args[2] != nil { | 110 | if args[2] != nil { | ||
| 110 | arg2 = args[2].(int) | 111 | arg2 = args[2].(int) | ||
| 111 | } | 112 | } | ||
| 113 | + | var arg3 int64 | |||
| 114 | + | if args[3] != nil { | |||
| 115 | + | arg3 = args[3].(int64) | |||
| 116 | + | } | |||
| 112 | run( | 117 | run( | ||
| 113 | arg0, | 118 | arg0, | ||
| 114 | arg1, | 119 | arg1, | ||
| 115 | arg2, | 120 | arg2, | ||
| 121 | + | arg3, | |||
| 116 | ) | 122 | ) | ||
| 117 | }) | 123 | }) | ||
| 118 | return _c | 124 | return _c | ||
| @@ -123,7 +129,7 @@ func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, mor | |||||
| 123 | return _c | 129 | return _c | ||
| 124 | } | 130 | } | ||
| 125 | 131 | ||||
| 126 | - | func (_c *CISummaryStore_List_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, limit int) ([]domain.CIStepSummary, bool, error)) *CISummaryStore_List_Call { | 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 { |
| 127 | _c.Call.Return(run) | 133 | _c.Call.Return(run) | ||
| 128 | return _c | 134 | return _c | ||
| 129 | } | 135 | } | ||
M internal/ports/mocks/RunService.go +18 -12
| @@ -370,8 +370,8 @@ func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p d | |||
| 370 | 370 | } | |
| 371 | 371 | ||
| 372 | 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) (ports.SummaryList, error) { | |
| 374 | - | ret := _mock.Called(ctx, p, repo, jobID, attempt) | |
| 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 | 375 | ||
| 376 | 376 | if len(ret) == 0 { | |
| 377 | 377 | panic("no return value specified for GetStepSummaries") | |
| @@ -379,16 +379,16 @@ func (_mock *RunService) GetStepSummaries(ctx context.Context, p domain.Principa | |||
| 379 | 379 | ||
| 380 | 380 | var r0 ports.SummaryList | |
| 381 | 381 | var r1 error | |
| 382 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) (ports.SummaryList, error)); ok { | |
| 383 | - | return returnFunc(ctx, p, repo, jobID, attempt) | |
| 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 | 384 | } | |
| 385 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) ports.SummaryList); ok { | |
| 386 | - | r0 = returnFunc(ctx, p, repo, jobID, attempt) | |
| 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 | 387 | } else { | |
| 388 | 388 | r0 = ret.Get(0).(ports.SummaryList) | |
| 389 | 389 | } | |
| 390 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32) error); ok { | |
| 391 | - | r1 = returnFunc(ctx, p, repo, jobID, attempt) | |
| 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 | 392 | } else { | |
| 393 | 393 | r1 = ret.Error(1) | |
| 394 | 394 | } | |
| @@ -406,11 +406,12 @@ type RunService_GetStepSummaries_Call struct { | |||
| 406 | 406 | // - repo domain.RepoName | |
| 407 | 407 | // - jobID string | |
| 408 | 408 | // - attempt int32 | |
| 409 | - | func (_e *RunService_Expecter) GetStepSummaries(ctx any, p any, repo any, jobID any, attempt any) *RunService_GetStepSummaries_Call { | |
| 410 | - | return &RunService_GetStepSummaries_Call{Call: _e.mock.On("GetStepSummaries", ctx, p, repo, jobID, attempt)} | |
| 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)} | |
| 411 | 412 | } | |
| 412 | 413 | ||
| 413 | - | func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32)) *RunService_GetStepSummaries_Call { | |
| 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 { | |
| 414 | 415 | _c.Call.Run(func(args mock.Arguments) { | |
| 415 | 416 | var arg0 context.Context | |
| 416 | 417 | if args[0] != nil { | |
| @@ -432,12 +433,17 @@ func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p | |||
| 432 | 433 | if args[4] != nil { | |
| 433 | 434 | arg4 = args[4].(int32) | |
| 434 | 435 | } | |
| 436 | + | var arg5 int64 | |
| 437 | + | if args[5] != nil { | |
| 438 | + | arg5 = args[5].(int64) | |
| 439 | + | } | |
| 435 | 440 | run( | |
| 436 | 441 | arg0, | |
| 437 | 442 | arg1, | |
| 438 | 443 | arg2, | |
| 439 | 444 | arg3, | |
| 440 | 445 | arg4, | |
| 446 | + | arg5, | |
| 441 | 447 | ) | |
| 442 | 448 | }) | |
| 443 | 449 | return _c | |
| @@ -448,7 +454,7 @@ func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList | |||
| 448 | 454 | return _c | |
| 449 | 455 | } | |
| 450 | 456 | ||
| 451 | - | func (_c *RunService_GetStepSummaries_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error)) *RunService_GetStepSummaries_Call { | |
| 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 { | |
| 452 | 458 | _c.Call.Return(run) | |
| 453 | 459 | return _c | |
| 454 | 460 | } | |
| @@ -370,8 +370,8 @@ func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p d | |||||
| 370 | } | 370 | } | ||
| 371 | 371 | ||||
| 372 | // GetStepSummaries provides a mock function for the type RunService | 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) (ports.SummaryList, error) { | 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) | 374 | + | ret := _mock.Called(ctx, p, repo, jobID, attempt, maxBytes) |
| 375 | 375 | ||||
| 376 | if len(ret) == 0 { | 376 | if len(ret) == 0 { | ||
| 377 | panic("no return value specified for GetStepSummaries") | 377 | panic("no return value specified for GetStepSummaries") | ||
| @@ -379,16 +379,16 @@ func (_mock *RunService) GetStepSummaries(ctx context.Context, p domain.Principa | |||||
| 379 | 379 | ||||
| 380 | var r0 ports.SummaryList | 380 | var r0 ports.SummaryList | ||
| 381 | var r1 error | 381 | var r1 error | ||
| 382 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) (ports.SummaryList, error)); ok { | 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) | 383 | + | return returnFunc(ctx, p, repo, jobID, attempt, maxBytes) |
| 384 | } | 384 | } | ||
| 385 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) ports.SummaryList); ok { | 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) | 386 | + | r0 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes) |
| 387 | } else { | 387 | } else { | ||
| 388 | r0 = ret.Get(0).(ports.SummaryList) | 388 | r0 = ret.Get(0).(ports.SummaryList) | ||
| 389 | } | 389 | } | ||
| 390 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32) error); ok { | 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) | 391 | + | r1 = returnFunc(ctx, p, repo, jobID, attempt, maxBytes) |
| 392 | } else { | 392 | } else { | ||
| 393 | r1 = ret.Error(1) | 393 | r1 = ret.Error(1) | ||
| 394 | } | 394 | } | ||
| @@ -406,11 +406,12 @@ type RunService_GetStepSummaries_Call struct { | |||||
| 406 | // - repo domain.RepoName | 406 | // - repo domain.RepoName | ||
| 407 | // - jobID string | 407 | // - jobID string | ||
| 408 | // - attempt int32 | 408 | // - attempt int32 | ||
| 409 | - | func (_e *RunService_Expecter) GetStepSummaries(ctx any, p any, repo any, jobID any, attempt any) *RunService_GetStepSummaries_Call { | 409 | + | // - maxBytes int64 |
| 410 | - | return &RunService_GetStepSummaries_Call{Call: _e.mock.On("GetStepSummaries", ctx, p, repo, jobID, attempt)} | 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)} | |||
| 411 | } | 412 | } | ||
| 412 | 413 | ||||
| 413 | - | func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32)) *RunService_GetStepSummaries_Call { | 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 { |
| 414 | _c.Call.Run(func(args mock.Arguments) { | 415 | _c.Call.Run(func(args mock.Arguments) { | ||
| 415 | var arg0 context.Context | 416 | var arg0 context.Context | ||
| 416 | if args[0] != nil { | 417 | if args[0] != nil { | ||
| @@ -432,12 +433,17 @@ func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p | |||||
| 432 | if args[4] != nil { | 433 | if args[4] != nil { | ||
| 433 | arg4 = args[4].(int32) | 434 | arg4 = args[4].(int32) | ||
| 434 | } | 435 | } | ||
| 436 | + | var arg5 int64 | |||
| 437 | + | if args[5] != nil { | |||
| 438 | + | arg5 = args[5].(int64) | |||
| 439 | + | } | |||
| 435 | run( | 440 | run( | ||
| 436 | arg0, | 441 | arg0, | ||
| 437 | arg1, | 442 | arg1, | ||
| 438 | arg2, | 443 | arg2, | ||
| 439 | arg3, | 444 | arg3, | ||
| 440 | arg4, | 445 | arg4, | ||
| 446 | + | arg5, | |||
| 441 | ) | 447 | ) | ||
| 442 | }) | 448 | }) | ||
| 443 | return _c | 449 | return _c | ||
| @@ -448,7 +454,7 @@ func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList | |||||
| 448 | return _c | 454 | return _c | ||
| 449 | } | 455 | } | ||
| 450 | 456 | ||||
| 451 | - | func (_c *RunService_GetStepSummaries_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error)) *RunService_GetStepSummaries_Call { | 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 { |
| 452 | _c.Call.Return(run) | 458 | _c.Call.Return(run) | ||
| 453 | return _c | 459 | return _c | ||
| 454 | } | 460 | } | ||
M internal/usecase/ci_results.go +4 -3
| @@ -111,8 +111,9 @@ func (c *CI) ListAnnotations(ctx context.Context, p domain.Principal, name domai | |||
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 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. | |
| 115 | - | func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error) { | |
| 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) { | |
| 116 | 117 | if _, err := c.readableRepo(ctx, p, name); err != nil { | |
| 117 | 118 | return ports.SummaryList{}, err | |
| 118 | 119 | } | |
| @@ -120,7 +121,7 @@ func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name doma | |||
| 120 | 121 | if err != nil { | |
| 121 | 122 | return ports.SummaryList{}, err | |
| 122 | 123 | } | |
| 123 | - | list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries) | |
| 124 | + | list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries, max(maxBytes, 0)) | |
| 124 | 125 | if err != nil { | |
| 125 | 126 | return ports.SummaryList{}, err | |
| 126 | 127 | } | |
| @@ -111,8 +111,9 @@ func (c *CI) ListAnnotations(ctx context.Context, p domain.Principal, name domai | |||||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | // GetStepSummaries returns the summaries of a job at an attempt (0 for the | 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. | 114 | + | // latest): the first 20 steps that have one, by step number. With maxBytes |
| 115 | - | func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32) (ports.SummaryList, error) { | 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) { | |||
| 116 | if _, err := c.readableRepo(ctx, p, name); err != nil { | 117 | if _, err := c.readableRepo(ctx, p, name); err != nil { | ||
| 117 | return ports.SummaryList{}, err | 118 | return ports.SummaryList{}, err | ||
| 118 | } | 119 | } | ||
| @@ -120,7 +121,7 @@ func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name doma | |||||
| 120 | if err != nil { | 121 | if err != nil { | ||
| 121 | return ports.SummaryList{}, err | 122 | return ports.SummaryList{}, err | ||
| 122 | } | 123 | } | ||
| 123 | - | list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries) | 124 | + | list, more, err := c.cfg.Summaries.List(ctx, logRef(j), domain.CIMaxListedSummaries, max(maxBytes, 0)) |
| 124 | if err != nil { | 125 | if err != nil { | ||
| 125 | return ports.SummaryList{}, err | 126 | return ports.SummaryList{}, err | ||
| 126 | } | 127 | } | ||
M internal/usecase/ci_results_unit_test.go +24 -3
| @@ -177,7 +177,7 @@ func TestGetStepSummariesAndListAnnotations(t *testing.T) { | |||
| 177 | 177 | u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, nil) | |
| 178 | 178 | u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(2)).Return([]domain.CIJob{j}, nil) | |
| 179 | 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). | |
| 180 | + | u.sums.EXPECT().List(mock.Anything, ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}, domain.CIMaxListedSummaries, int64(0)). | |
| 181 | 181 | Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil) | |
| 182 | 182 | ctx := context.Background() | |
| 183 | 183 | ||
| @@ -187,7 +187,7 @@ func TestGetStepSummariesAndListAnnotations(t *testing.T) { | |||
| 187 | 187 | assert.Equal(t, jobIDOne, anns.Annotations[0].JobID) | |
| 188 | 188 | assert.False(t, anns.Truncated) | |
| 189 | 189 | ||
| 190 | - | sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0) | |
| 190 | + | sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0, 0) | |
| 191 | 191 | require.NoError(t, err) | |
| 192 | 192 | assert.True(t, sums.Truncated) | |
| 193 | 193 | assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries) | |
| @@ -226,6 +226,27 @@ func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) { | |||
| 226 | 226 | u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil) | |
| 227 | 227 | _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "") | |
| 228 | 228 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 229 | - | _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0) | |
| 229 | + | _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 0) | |
| 230 | 230 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 231 | 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 | + | } | |
| @@ -177,7 +177,7 @@ func TestGetStepSummariesAndListAnnotations(t *testing.T) { | |||||
| 177 | u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, 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) | 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) | 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). | 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) | 181 | Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil) | ||
| 182 | ctx := context.Background() | 182 | ctx := context.Background() | ||
| 183 | 183 | ||||
| @@ -187,7 +187,7 @@ func TestGetStepSummariesAndListAnnotations(t *testing.T) { | |||||
| 187 | assert.Equal(t, jobIDOne, anns.Annotations[0].JobID) | 187 | assert.Equal(t, jobIDOne, anns.Annotations[0].JobID) | ||
| 188 | assert.False(t, anns.Truncated) | 188 | assert.False(t, anns.Truncated) | ||
| 189 | 189 | ||||
| 190 | - | sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0) | 190 | + | sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0, 0) |
| 191 | require.NoError(t, err) | 191 | require.NoError(t, err) | ||
| 192 | assert.True(t, sums.Truncated) | 192 | assert.True(t, sums.Truncated) | ||
| 193 | assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries) | 193 | assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries) | ||
| @@ -226,6 +226,27 @@ func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) { | |||||
| 226 | u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil) | 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, "") | 227 | _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "") | ||
| 228 | assert.ErrorIs(t, err, domain.ErrNotFound) | 228 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 229 | - | _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0) | 229 | + | _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 0) |
| 230 | assert.ErrorIs(t, err, domain.ErrNotFound) | 230 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 231 | } | 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 | + | } | |||