v0.30.0 c899179

cisummaries.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package cisummaries implements ports.CISummaryStore with one Markdown file per
4// step under $ERMITE_DATA_DIR/ci/summaries/<run id>/<job id>/<attempt>/<step>.md.
5//
6// A summary is written to a temporary file of the same directory, synced, then
7// renamed over the final name: a reader sees the old summary or the new one,
8// never a part. The files of a run go with the logs of the run: the use case
9// that removes the logs removes them too.
10package cisummaries
11
12import (
13	"context"
14	"errors"
15	"fmt"
16	"io"
17	"io/fs"
18	"os"
19	"path/filepath"
20	"slices"
21	"strconv"
22	"strings"
23
24	"bnema.dev/ermite/internal/domain"
25	"bnema.dev/ermite/internal/ports"
26)
27
28// Dir is the directory of the summaries under the data directory.
29func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "summaries") }
30
31const ext = ".md"
32
33// Store is the file-backed summary store.
34type Store struct{ root string }
35
36var _ ports.CISummaryStore = (*Store)(nil)
37
38// New returns a store rooted at dir, which need not exist yet.
39func New(dir string) *Store { return &Store{root: dir} }
40
41// validRef rejects a reference that could escape the directory.
42func validRef(ref ports.CILogRef) bool {
43	if ref.RunID < 1 || ref.Attempt < 1 {
44		return false
45	}
46	_, _, _, ok := domain.ParseJobID(ref.JobID)
47	return ok
48}
49
50func (s *Store) jobDir(ref ports.CILogRef) string {
51	return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt)))
52}
53
54func (s *Store) path(ref ports.CILogRef, step int32) string {
55	return filepath.Join(s.jobDir(ref), strconv.Itoa(int(step))+ext)
56}
57
58// Put stores the summary of a step, or removes it when markdown is empty.
59//
60//hexcheck:ignore suspicious-business-logic-in-adapter validates storage coordinates and atomically writes a step summary, deleting its file when empty
61func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
62	if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
63		return domain.ErrNotFound
64	}
65	if markdown == "" {
66		if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
67			return fmt.Errorf("remove summary: %w", err)
68		}
69		return nil
70	}
71	dir := s.jobDir(ref)
72	if err := os.MkdirAll(dir, 0o700); err != nil {
73		return fmt.Errorf("create summary directory: %w", err)
74	}
75	f, err := os.CreateTemp(dir, ".tmp-*")
76	if err != nil {
77		return fmt.Errorf("create summary: %w", err)
78	}
79	tmp := f.Name()
80	_, werr := f.WriteString(markdown)
81	serr := f.Sync()
82	cerr := f.Close()
83	if err := errors.Join(werr, serr, cerr); err != nil {
84		_ = os.Remove(tmp)
85		return fmt.Errorf("write summary: %w", err)
86	}
87	if err := os.Rename(tmp, s.path(ref, step)); err != nil {
88		_ = os.Remove(tmp)
89		return fmt.Errorf("store summary: %w", err)
90	}
91	return nil
92}
93
94// List returns the summaries of the steps that have one, by step number.
95func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
96	if !validRef(ref) {
97		return nil, false, nil
98	}
99	entries, err := os.ReadDir(s.jobDir(ref))
100	if errors.Is(err, fs.ErrNotExist) {
101		return nil, false, nil
102	}
103	if err != nil {
104		return nil, false, fmt.Errorf("list summaries: %w", err)
105	}
106	var steps []int32
107	for _, e := range entries {
108		name, ok := strings.CutSuffix(e.Name(), ext)
109		if !ok || e.IsDir() {
110			continue // a temporary file, or something else
111		}
112		n, err := strconv.ParseInt(name, 10, 32)
113		if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
114			continue
115		}
116		steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
117	}
118	slices.Sort(steps)
119	more := limit > 0 && len(steps) > limit
120	if more {
121		steps = steps[:limit]
122	}
123	out := make([]domain.CIStepSummary, 0, len(steps))
124	var read int64
125	exhausted := false
126	for _, n := range steps {
127		if exhausted {
128			out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
129			continue
130		}
131		if maxBytes > 0 {
132			// Size the file first: a summary that does not fit is not read.
133			info, err := os.Stat(s.path(ref, n))
134			if errors.Is(err, fs.ErrNotExist) {
135				continue // removed meanwhile
136			}
137			if err != nil {
138				return nil, false, fmt.Errorf("stat summary: %w", err)
139			}
140			if read+info.Size() > maxBytes {
141				exhausted = true
142				out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
143				continue
144			}
145		}
146		b, err := readCapped(s.path(ref, n), maxBytes, read)
147		if errors.Is(err, fs.ErrNotExist) {
148			continue // removed meanwhile
149		}
150		if errors.Is(err, errOverBudget) {
151			exhausted = true // replaced by a larger file since the stat
152			out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
153			continue
154		}
155		if err != nil {
156			return nil, false, fmt.Errorf("read summary: %w", err)
157		}
158		read += int64(len(b))
159		out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
160	}
161	return out, more, nil
162}
163
164var errOverBudget = errors.New("over budget")
165
166// readCapped reads a file, at most what is left of maxBytes after read bytes
167// (no cap when maxBytes is 0): a file that is longer than that, because it grew
168// since it was sized, is errOverBudget and its content is dropped.
169func readCapped(path string, maxBytes, read int64) ([]byte, error) {
170	f, err := os.Open(path)
171	if err != nil {
172		return nil, err
173	}
174	defer func() { _ = f.Close() }()
175	if maxBytes <= 0 {
176		return io.ReadAll(f)
177	}
178	left := maxBytes - read
179	b, err := io.ReadAll(io.LimitReader(f, left+1))
180	if err != nil {
181		return nil, err
182	}
183	if int64(len(b)) > left {
184		return nil, errOverBudget
185	}
186	return b, nil
187}
188
189// RemoveJob deletes the summaries of one execution of a job.
190func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
191	if !validRef(ref) {
192		return nil
193	}
194	if err := os.RemoveAll(s.jobDir(ref)); err != nil {
195		return fmt.Errorf("remove summaries: %w", err)
196	}
197	return nil
198}
199
200// RemoveRuns deletes the summaries of the runs.
201func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
202	var errs []error
203	for _, id := range runIDs {
204		if id < 1 {
205			continue
206		}
207		if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
208			errs = append(errs, err)
209		}
210	}
211	return errors.Join(errs...)
212}
213
214// RunIDs lists the runs that have a summary directory.
215func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
216	entries, err := os.ReadDir(s.root)
217	if errors.Is(err, fs.ErrNotExist) {
218		return nil, nil
219	}
220	if err != nil {
221		return nil, fmt.Errorf("list summaries: %w", err)
222	}
223	var ids []int64
224	for _, e := range entries {
225		if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
226			ids = append(ids, id)
227		}
228	}
229	return ids, nil
230}

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 45

↑↓ moveenter openesc close