detached 3bc6931

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +734 -411
M internal/adapters/cisummaries/cisummaries.go +55 -2
@@ -13,6 +13,7 @@ import (
1313 "context"
1414 "errors"
1515 "fmt"
16+ "io"
1617 "io/fs"
1718 "os"
1819 "path/filepath"
@@ -89,7 +90,7 @@ func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown
8990 }
9091
9192 // 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) {
9394 if !validRef(ref) {
9495 return nil, false, nil
9596 }
@@ -118,19 +119,71 @@ func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int) ([]domain
118119 steps = steps[:limit]
119120 }
120121 out := make([]domain.CIStepSummary, 0, len(steps))
122+ var read int64
123+ exhausted := false
121124 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)
123145 if errors.Is(err, fs.ErrNotExist) {
124146 continue // removed meanwhile
125147 }
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+ }
126153 if err != nil {
127154 return nil, false, fmt.Errorf("read summary: %w", err)
128155 }
156+ read += int64(len(b))
129157 out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
130158 }
131159 return out, more, nil
132160 }
133161
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+
134187 // RemoveJob deletes the summaries of one execution of a job.
135188 func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
136189 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, nil95 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 meanwhile146 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, nil159 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) {
2323 root := t.TempDir()
2424 s := cisummaries.New(root)
2525
26- got, more, err := s.List(ctx, ref, 20)
26+ got, more, err := s.List(ctx, ref, 20, 0)
2727 require.NoError(t, err)
2828 assert.Empty(t, got)
2929 assert.False(t, more)
@@ -35,23 +35,23 @@ func TestPutListReplaceAndRemove(t *testing.T) {
3535 require.NoError(t, err)
3636 assert.Equal(t, "two", string(b))
3737
38- got, _, err = s.List(ctx, ref, 20)
38+ got, _, err = s.List(ctx, ref, 20, 0)
3939 require.NoError(t, err)
4040 assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name")
4141
4242 // Replace, and an empty markdown removes.
4343 require.NoError(t, s.Put(ctx, ref, 2, "again"))
44- got, _, _ = s.List(ctx, ref, 20)
44+ got, _, _ = s.List(ctx, ref, 20, 0)
4545 assert.Equal(t, "again", got[0].Markdown)
4646 require.NoError(t, s.Put(ctx, ref, 2, ""))
4747 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)
4949 require.Len(t, got, 1)
5050 assert.EqualValues(t, 10, got[0].Step)
5151
5252 // Another attempt and another job have their own.
5353 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)
5555 assert.Empty(t, got)
5656
5757 // A write leaves no temporary file behind.
@@ -68,12 +68,12 @@ func TestListStopsAtTheLimit(t *testing.T) {
6868 for i := int32(1); i <= 25; i++ {
6969 require.NoError(t, s.Put(ctx, ref, i, "s"))
7070 }
71- got, more, err := s.List(ctx, ref, 20)
71+ got, more, err := s.List(ctx, ref, 20, 0)
7272 require.NoError(t, err)
7373 assert.Len(t, got, 20)
7474 assert.True(t, more)
7575 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)
7777 assert.Len(t, got, 25)
7878 assert.False(t, more)
7979 }
@@ -88,7 +88,7 @@ func TestListIgnoresStrayFiles(t *testing.T) {
8888 for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} {
8989 require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600))
9090 }
91- got, _, err := s.List(ctx, ref, 20)
91+ got, _, err := s.List(ctx, ref, 20, 0)
9292 require.NoError(t, err)
9393 require.Len(t, got, 1)
9494 assert.Equal(t, "one", got[0].Markdown)
@@ -110,15 +110,15 @@ func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) {
110110 assert.ElementsMatch(t, []int64{7, 8}, ids)
111111
112112 require.NoError(t, s.RemoveJob(ctx, ref))
113- got, _, _ := s.List(ctx, ref, 20)
113+ got, _, _ := s.List(ctx, ref, 20, 0)
114114 assert.Empty(t, got)
115- got, _, _ = s.List(ctx, second, 20)
115+ got, _, _ = s.List(ctx, second, 20, 0)
116116 assert.Len(t, got, 1, "the other job of the run stays")
117117
118118 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)
120120 assert.Empty(t, got)
121- got, _, _ = s.List(ctx, third, 20)
121+ got, _, _ = s.List(ctx, third, 20, 0)
122122 assert.Len(t, got, 1)
123123 ids, _ = s.RunIDs(ctx)
124124 assert.Equal(t, []int64{8}, ids)
@@ -136,7 +136,7 @@ func TestRefsCannotEscape(t *testing.T) {
136136 {RunID: 7, JobID: "7-0-0", Attempt: 0},
137137 } {
138138 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)
140140 require.NoError(t, err)
141141 assert.Empty(t, got)
142142 }
@@ -151,7 +151,61 @@ func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) {
151151 s := cisummaries.New(t.TempDir())
152152 big := strings.Repeat("x", domain.CIMaxSummaryBytes)
153153 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)
155155 require.NoError(t, err)
156156 assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes)
157157 }
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[
367367 if err != nil {
368368 return nil, ciError(ctx, err)
369369 }
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)
371371 if err != nil {
372372 return nil, ciError(ctx, err)
373373 }
@@ -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) {
175175 Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}},
176176 Truncated: true,
177177 }, 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{
179179 Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true,
180180 }, nil).Once()
181181 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) {
6262 <div id="run-body">
6363 @RunBody(p)
6464 </div>
65- @RunSummaries(p.Summaries)
65+ @RunSummaries(p.Summaries, p.SummariesMore)
6666 }
6767 }
6868
@@ -181,7 +181,7 @@ templ JobAnnotations(anns []AnnotationView, more bool) {
181181 // RunSummaries is the job summaries of a run, one block per step that wrote
182182 // one. The Markdown is rendered by the README renderer: raw HTML dropped, URLs
183183 // filtered, images shown as their alt text.
184-templ RunSummaries(sums []JobSummaries) {
184+templ RunSummaries(sums []JobSummaries, more bool) {
185185 for _, j := range sums {
186186 <section class="summaries" aria-label={ "Summary of " + j.Job }>
187187 <h3>
@@ -207,6 +207,9 @@ templ RunSummaries(sums []JobSummaries) {
207207 }
208208 </section>
209209 }
210+ if more {
211+ <p class="mute">more summaries are not shown: the summaries of the page are over { humanSize(maxSummaryPageBytes) }.</p>
212+ }
210213 }
211214
212215 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 wrote181 // 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, URLs182 // 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 {
179179 AnnotationsMore bool
180180 // Summaries are the job summaries, rendered; only the full page has them.
181181 Summaries []JobSummaries
182- Active bool
182+ // SummariesMore says that some summaries are not shown (the page budget).
183+ SummariesMore bool
184+ Active bool
183185 // detail is what the page was built from.
184186 detail ports.RunDetail
185187 // 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
294296 }
295297 // The summaries are part of the page, not of the live body: the page
296298 // 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 {
298300 return nil, 0, err
299301 }
300302 l := s.repoLayout(ctx, d, "actions")
@@ -179,7 +179,9 @@ type RunPage struct {
179 AnnotationsMore bool179 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 []JobSummaries181 Summaries []JobSummaries
182- Active bool182+ // 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.RunDetail186 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 page297 // 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, err300 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 (
2121
2222 // maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries
2323 // together; the ones past it are not shown, with a note.
24-const maxSummaryPageBytes = 4 << 20
24+const maxSummaryPageBytes int64 = 4 << 20
2525
2626 // AnnotationView is an annotation as shown.
2727 type AnnotationView struct {
@@ -156,17 +156,21 @@ type JobSummaries struct {
156156 }
157157
158158 // 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) {
163164 for _, j := range jobs {
164165 if j.Phase != domain.CIPhaseRunning && !j.Ended() {
165166 continue // no step has run
166167 }
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)
168172 if err != nil {
169- return nil, err
173+ return nil, false, err
170174 }
171175 if len(res.Summaries) == 0 {
172176 continue
@@ -179,12 +183,14 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun
179183 name = st.name
180184 }
181185 v := SummaryView{Step: name}
182- if len(sm.Markdown) > budget {
186+ if sm.Omitted {
183187 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
184190 js.Steps = append(js.Steps, v)
185191 continue
186192 }
187- budget -= len(sm.Markdown)
193+ budget -= int64(len(sm.Markdown))
188194 prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-"
189195 h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix)
190196 if err != nil {
@@ -196,7 +202,25 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun
196202 }
197203 out = append(out, js)
198204 }
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
200224 }
201225
202226 // 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 summaries22 // 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 << 2024+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 the158 // 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 := maxSummaryPageBytes161+// before it reads them: a page view reads at most budget bytes. Once it is used
162- var out []JobSummaries162+// 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 run166 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, err173+ return nil, false, err
170 }174 }
171 if len(res.Summaries) == 0 {175 if len(res.Summaries) == 0 {
172 continue176 continue
@@ -179,12 +183,14 @@ func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun
179 name = st.name183 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 continue191 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, nil205+ 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) {
4848 Jobs: []ports.JobView{job}, LatestAttempt: 1,
4949 }, nil)
5050 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{
5252 Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true,
5353 }, nil)
5454 browse := mocks.NewBrowseService(t)
@@ -128,3 +128,110 @@ func TestRepoPath(t *testing.T) {
128128 assert.False(t, repoPath(bad), "%q", bad)
129129 }
130130 }
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) {
120120 }
121121 }
122122
123+ // The commit of the run of a job page is read once here: the annotations of
124+ // the live job fragments link to files at it, and it never changes, so a
125+ // job update does not read the run again. A run that is not there or not
126+ // readable refuses the stream like a missing repository.
127+ var sha string
128+ if repo != "" && q.Get("view") == "job" && s.cfg.Runs != nil {
129+ cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
130+ var err error
131+ sha, err = s.jobRunSha(cctx, repo, q.Get("job"))
132+ ccancel()
133+ if err != nil {
134+ s.denyEvents(ctx, w, err)
135+ return
136+ }
137+ }
138+
123139 // The watch runs in a goroutine and hands each event to this one through
124140 // an unbuffered channel: a visitor that reads slowly holds the watch in
125141 // its send, falls behind the buffer of the use case, and gets a gap.
@@ -202,7 +218,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
202218 return
203219 }
204220
205- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")}
221+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
206222 handle := func(m recvd) bool {
207223 if m.err != nil {
208224 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
@@ -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 through139 // 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 in140 // 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 return218 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 {
2626 head string // commits: the commit listed first
2727 n string // mr: the number of the merge request shown; run: the run id
2828 job string // job: the id of the job shown
29+ // sha is the commit of the run of the job shown, read once when the stream
30+ // is set up: the annotations link to files at it, and it never changes.
31+ sha string
2932 }
3033
3134 // renderFragment renders a component to the data of an event.
@@ -478,6 +481,9 @@ func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
478481 // jobState tells the job page that its state or its steps changed: the page
479482 // reloads the part of the log after the last line it holds (ermite.js).
480483 func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
484+ if l.sha == "" {
485+ return gap, nil // the stream was set up without the commit of the run
486+ }
481487 d, err := l.s.loadRepo(ctx, l.repo)
482488 if err != nil {
483489 return nil, err
@@ -496,11 +502,7 @@ func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
496502 if err != nil {
497503 return nil, err
498504 }
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)
504506 if err != nil {
505507 return nil, err
506508 }
@@ -26,6 +26,9 @@ type liveStream struct {
26 head string // commits: the commit listed first26 head string // commits: the commit listed first
27 n string // mr: the number of the merge request shown; run: the run id27 n string // mr: the number of the merge request shown; run: the run id
28 job string // job: the id of the job shown28 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 page481 // 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, err489 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, err503 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, err507 return nil, err
506 }508 }
M internal/domain/ci_annotation.go +3 -0
@@ -57,6 +57,9 @@ type CIAnnotation struct {
5757 type CIStepSummary struct {
5858 Step int32
5959 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
6063 }
6164
6265 // 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 int3258 Step int32
59 Markdown string59 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 the65 // 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 {
218218 // appears whole or not at all. An empty markdown removes the summary.
219219 Put(ctx context.Context, ref CILogRef, step int32, markdown string) error
220220 // 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)
223227 // RemoveJob deletes the summaries of one execution of a job.
224228 RemoveJob(ctx context.Context, ref CILogRef) error
225229 // RemoveRuns deletes the summaries of the runs.
@@ -531,8 +535,10 @@ type RunService interface {
531535 // attempt in which it executed.
532536 ListAnnotations(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32, jobID string) (AnnotationList, error)
533537 // 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)
536542 ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
537543 RemoveRunner(ctx context.Context, p domain.Principal, id string) error
538544 }
@@ -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) error219 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) error228 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 the537 // 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) error543 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 {
4949 }
5050
5151 // 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)
5454
5555 if len(ret) == 0 {
5656 panic("no return value specified for List")
@@ -59,23 +59,23 @@ func (_mock *CISummaryStore) List(ctx context.Context, ref ports.CILogRef, limit
5959 var r0 []domain.CIStepSummary
6060 var r1 bool
6161 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)
6464 }
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)
6767 } else {
6868 if ret.Get(0) != nil {
6969 r0 = ret.Get(0).([]domain.CIStepSummary)
7070 }
7171 }
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)
7474 } else {
7575 r1 = ret.Get(1).(bool)
7676 }
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)
7979 } else {
8080 r2 = ret.Error(2)
8181 }
@@ -91,11 +91,12 @@ type CISummaryStore_List_Call struct {
9191 // - ctx context.Context
9292 // - ref ports.CILogRef
9393 // - 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)}
9697 }
9798
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 {
99100 _c.Call.Run(func(args mock.Arguments) {
100101 var arg0 context.Context
101102 if args[0] != nil {
@@ -109,10 +110,15 @@ func (_c *CISummaryStore_List_Call) Run(run func(ctx context.Context, ref ports.
109110 if args[2] != nil {
110111 arg2 = args[2].(int)
111112 }
113+ var arg3 int64
114+ if args[3] != nil {
115+ arg3 = args[3].(int64)
116+ }
112117 run(
113118 arg0,
114119 arg1,
115120 arg2,
121+ arg3,
116122 )
117123 })
118124 return _c
@@ -123,7 +129,7 @@ func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, mor
123129 return _c
124130 }
125131
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 {
127133 _c.Call.Return(run)
128134 return _c
129135 }
@@ -49,8 +49,8 @@ func (_m *CISummaryStore) EXPECT() *CISummaryStore_Expecter {
49 }49 }
50 50
51 // List provides a mock function for the type CISummaryStore51 // 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.CIStepSummary59 var r0 []domain.CIStepSummary
60 var r1 bool60 var r1 bool
61 var r2 error61 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.Context91 // - ctx context.Context
92 // - ref ports.CILogRef92 // - ref ports.CILogRef
93 // - limit int93 // - 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.Context101 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 _c124 return _c
@@ -123,7 +129,7 @@ func (_c *CISummaryStore_List_Call) Return(summaries []domain.CIStepSummary, mor
123 return _c129 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 _c134 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
370370 }
371371
372372 // 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)
375375
376376 if len(ret) == 0 {
377377 panic("no return value specified for GetStepSummaries")
@@ -379,16 +379,16 @@ func (_mock *RunService) GetStepSummaries(ctx context.Context, p domain.Principa
379379
380380 var r0 ports.SummaryList
381381 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)
384384 }
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)
387387 } else {
388388 r0 = ret.Get(0).(ports.SummaryList)
389389 }
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)
392392 } else {
393393 r1 = ret.Error(1)
394394 }
@@ -406,11 +406,12 @@ type RunService_GetStepSummaries_Call struct {
406406 // - repo domain.RepoName
407407 // - jobID string
408408 // - 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)}
411412 }
412413
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 {
414415 _c.Call.Run(func(args mock.Arguments) {
415416 var arg0 context.Context
416417 if args[0] != nil {
@@ -432,12 +433,17 @@ func (_c *RunService_GetStepSummaries_Call) Run(run func(ctx context.Context, p
432433 if args[4] != nil {
433434 arg4 = args[4].(int32)
434435 }
436+ var arg5 int64
437+ if args[5] != nil {
438+ arg5 = args[5].(int64)
439+ }
435440 run(
436441 arg0,
437442 arg1,
438443 arg2,
439444 arg3,
440445 arg4,
446+ arg5,
441447 )
442448 })
443449 return _c
@@ -448,7 +454,7 @@ func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList
448454 return _c
449455 }
450456
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 {
452458 _c.Call.Return(run)
453459 return _c
454460 }
@@ -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 RunService372 // 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.SummaryList380 var r0 ports.SummaryList
381 var r1 error381 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.RepoName406 // - repo domain.RepoName
407 // - jobID string407 // - jobID string
408 // - attempt int32408 // - 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.Context416 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 _c449 return _c
@@ -448,7 +454,7 @@ func (_c *RunService_GetStepSummaries_Call) Return(summaryList ports.SummaryList
448 return _c454 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 _c459 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
111111 }
112112
113113 // 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) {
116117 if _, err := c.readableRepo(ctx, p, name); err != nil {
117118 return ports.SummaryList{}, err
118119 }
@@ -120,7 +121,7 @@ func (c *CI) GetStepSummaries(ctx context.Context, p domain.Principal, name doma
120121 if err != nil {
121122 return ports.SummaryList{}, err
122123 }
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))
124125 if err != nil {
125126 return ports.SummaryList{}, err
126127 }
@@ -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 the113 // 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{}, err118 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{}, err122 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{}, err126 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) {
177177 u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, nil)
178178 u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(2)).Return([]domain.CIJob{j}, nil)
179179 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)).
181181 Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil)
182182 ctx := context.Background()
183183
@@ -187,7 +187,7 @@ func TestGetStepSummariesAndListAnnotations(t *testing.T) {
187187 assert.Equal(t, jobIDOne, anns.Annotations[0].JobID)
188188 assert.False(t, anns.Truncated)
189189
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)
191191 require.NoError(t, err)
192192 assert.True(t, sums.Truncated)
193193 assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries)
@@ -226,6 +226,27 @@ func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) {
226226 u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil)
227227 _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
228228 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)
230230 assert.ErrorIs(t, err, domain.ErrNotFound)
231231 }
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+}

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close