v0.10.1 e4c85c7

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.
59func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
60	if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
61		return domain.ErrNotFound
62	}
63	if markdown == "" {
64		if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
65			return fmt.Errorf("remove summary: %w", err)
66		}
67		return nil
68	}
69	dir := s.jobDir(ref)
70	if err := os.MkdirAll(dir, 0o700); err != nil {
71		return fmt.Errorf("create summary directory: %w", err)
72	}
73	f, err := os.CreateTemp(dir, ".tmp-*")
74	if err != nil {
75		return fmt.Errorf("create summary: %w", err)
76	}
77	tmp := f.Name()
78	_, werr := f.WriteString(markdown)
79	serr := f.Sync()
80	cerr := f.Close()
81	if err := errors.Join(werr, serr, cerr); err != nil {
82		_ = os.Remove(tmp)
83		return fmt.Errorf("write summary: %w", err)
84	}
85	if err := os.Rename(tmp, s.path(ref, step)); err != nil {
86		_ = os.Remove(tmp)
87		return fmt.Errorf("store summary: %w", err)
88	}
89	return nil
90}
91
92// List returns the summaries of the steps that have one, by step number.
93func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
94	if !validRef(ref) {
95		return nil, false, nil
96	}
97	entries, err := os.ReadDir(s.jobDir(ref))
98	if errors.Is(err, fs.ErrNotExist) {
99		return nil, false, nil
100	}
101	if err != nil {
102		return nil, false, fmt.Errorf("list summaries: %w", err)
103	}
104	var steps []int32
105	for _, e := range entries {
106		name, ok := strings.CutSuffix(e.Name(), ext)
107		if !ok || e.IsDir() {
108			continue // a temporary file, or something else
109		}
110		n, err := strconv.ParseInt(name, 10, 32)
111		if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
112			continue
113		}
114		steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
115	}
116	slices.Sort(steps)
117	more := limit > 0 && len(steps) > limit
118	if more {
119		steps = steps[:limit]
120	}
121	out := make([]domain.CIStepSummary, 0, len(steps))
122	var read int64
123	exhausted := false
124	for _, n := range steps {
125		if exhausted {
126			out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
127			continue
128		}
129		if maxBytes > 0 {
130			// Size the file first: a summary that does not fit is not read.
131			info, err := os.Stat(s.path(ref, n))
132			if errors.Is(err, fs.ErrNotExist) {
133				continue // removed meanwhile
134			}
135			if err != nil {
136				return nil, false, fmt.Errorf("stat summary: %w", err)
137			}
138			if read+info.Size() > maxBytes {
139				exhausted = true
140				out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
141				continue
142			}
143		}
144		b, err := readCapped(s.path(ref, n), maxBytes, read)
145		if errors.Is(err, fs.ErrNotExist) {
146			continue // removed meanwhile
147		}
148		if errors.Is(err, errOverBudget) {
149			exhausted = true // replaced by a larger file since the stat
150			out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
151			continue
152		}
153		if err != nil {
154			return nil, false, fmt.Errorf("read summary: %w", err)
155		}
156		read += int64(len(b))
157		out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
158	}
159	return out, more, nil
160}
161
162var 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.
167func readCapped(path string, maxBytes, read int64) ([]byte, error) {
168	f, err := os.Open(path)
169	if err != nil {
170		return nil, err
171	}
172	defer func() { _ = f.Close() }()
173	if maxBytes <= 0 {
174		return io.ReadAll(f)
175	}
176	left := maxBytes - read
177	b, err := io.ReadAll(io.LimitReader(f, left+1))
178	if err != nil {
179		return nil, err
180	}
181	if int64(len(b)) > left {
182		return nil, errOverBudget
183	}
184	return b, nil
185}
186
187// RemoveJob deletes the summaries of one execution of a job.
188func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
189	if !validRef(ref) {
190		return nil
191	}
192	if err := os.RemoveAll(s.jobDir(ref)); err != nil {
193		return fmt.Errorf("remove summaries: %w", err)
194	}
195	return nil
196}
197
198// RemoveRuns deletes the summaries of the runs.
199func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
200	var errs []error
201	for _, id := range runIDs {
202		if id < 1 {
203			continue
204		}
205		if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
206			errs = append(errs, err)
207		}
208	}
209	return errors.Join(errs...)
210}
211
212// RunIDs lists the runs that have a summary directory.
213func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
214	entries, err := os.ReadDir(s.root)
215	if errors.Is(err, fs.ErrNotExist) {
216		return nil, nil
217	}
218	if err != nil {
219		return nil, fmt.Errorf("list summaries: %w", err)
220	}
221	var ids []int64
222	for _, e := range entries {
223		if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
224			ids = append(ids, id)
225		}
226	}
227	return ids, nil
228}

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