v0.29.0 21b3150

logger.go RawHistoryFolder

1package logging
2
3import (
4	"errors"
5	"io"
6	"os"
7	"path/filepath"
8	"sync"
9	"time"
10
11	"github.com/rs/zerolog"
12)
13
14const (
15	logFormatConsole  = "console"
16	logFormatJSON     = "json"
17	logFormatText     = "text"
18	ConsoleTimeFormat = "15:04:05"
19)
20
21// Config holds logging configuration
22type Config struct {
23	Level      zerolog.Level
24	Format     string // "json" or "console"
25	TimeFormat string
26}
27
28// FileConfig holds file logging configuration
29type FileConfig struct {
30	Enabled       bool
31	LogDir        string
32	SessionID     string
33	WriteToStderr bool // if false, logs go to file only (not stderr)
34}
35
36// DefaultConfig returns sensible defaults
37func DefaultConfig() Config {
38	return Config{
39		Level:      zerolog.InfoLevel,
40		Format:     logFormatConsole,
41		TimeFormat: time.RFC3339,
42	}
43}
44
45// New creates a new zerolog logger with the given configuration
46func New(cfg Config) zerolog.Logger {
47	var output io.Writer = os.Stderr
48
49	switch cfg.Format {
50	case logFormatConsole:
51		output = zerolog.ConsoleWriter{
52			Out:        os.Stderr,
53			TimeFormat: cfg.TimeFormat,
54		}
55	case logFormatJSON:
56		// JSON is the default zerolog format
57		output = os.Stderr
58	}
59
60	return zerolog.New(output).
61		Level(cfg.Level).
62		With().
63		Timestamp().
64		Logger()
65}
66
67// NewWithFile creates a logger that writes to stderr and/or a session log file.
68// The session file uses JSON format for easy parsing by the CLI logs command.
69// LogDir must exist before calling this function (handled by config.EnsureDirectories).
70// Returns the logger and a cleanup function to close the file.
71func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
72	const (
73		logDirPerm  = 0o755
74		logFilePerm = 0o644
75	)
76	var writers []io.Writer
77	cleanup := func() {}
78
79	// Stderr output (only if enabled)
80	if fileCfg.WriteToStderr {
81		switch cfg.Format {
82		case logFormatConsole, logFormatText, "":
83			writers = append(writers, zerolog.ConsoleWriter{
84				Out:        os.Stderr,
85				TimeFormat: cfg.TimeFormat,
86			})
87		case logFormatJSON:
88			writers = append(writers, os.Stderr)
89		default:
90			writers = append(writers, zerolog.ConsoleWriter{
91				Out:        os.Stderr,
92				TimeFormat: cfg.TimeFormat,
93			})
94		}
95	}
96
97	// Session file output (JSON format for parsing)
98	if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
99		// Ensure log directory exists (LogDir is from config, may not have logs subdir)
100		if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
101			return zerolog.Logger{}, nil, err
102		}
103
104		filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
105		file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
106		if err != nil {
107			return zerolog.Logger{}, nil, err
108		}
109
110		sessionWriter := newSessionFileWriter(file)
111		writers = append(writers, sessionWriter)
112		cleanup = func() { _ = sessionWriter.Close() }
113	}
114
115	// Fallback to discard if no writers configured
116	if len(writers) == 0 {
117		writers = append(writers, io.Discard)
118	}
119
120	multi := zerolog.MultiLevelWriter(writers...)
121	ctx := zerolog.New(multi).
122		Level(cfg.Level).
123		With().
124		Timestamp()
125
126	return ctx.Logger(), cleanup, nil
127}
128
129type sessionFileWriter struct {
130	mu     sync.Mutex
131	file   *os.File
132	closed bool
133}
134
135func newSessionFileWriter(file *os.File) *sessionFileWriter {
136	return &sessionFileWriter{file: file}
137}
138
139func (w *sessionFileWriter) Write(p []byte) (int, error) {
140	w.mu.Lock()
141	defer w.mu.Unlock()
142
143	if w.closed || w.file == nil {
144		return len(p), nil
145	}
146
147	n, err := w.file.Write(p)
148	if errors.Is(err, os.ErrClosed) {
149		w.closed = true
150		w.file = nil
151		return len(p), nil
152	}
153
154	return n, err
155}
156
157func (w *sessionFileWriter) Close() error {
158	w.mu.Lock()
159	defer w.mu.Unlock()
160
161	if w.closed || w.file == nil {
162		return nil
163	}
164
165	err := w.file.Close()
166	w.closed = true
167	w.file = nil
168	if errors.Is(err, os.ErrClosed) {
169		return nil
170	}
171	return err
172}
173
174// NewFromEnv creates a logger based on environment variables
175// DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
176// DUMBER_LOG_FORMAT: json, console (default: console)
177func NewFromEnv() zerolog.Logger {
178	cfg := DefaultConfig()
179
180	if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
181		cfg.Level = ParseLevel(level)
182	}
183
184	if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
185		switch format {
186		case logFormatJSON, logFormatConsole, logFormatText:
187			cfg.Format = format
188		}
189	}
190
191	return New(cfg)
192}
193
194// NewFromConfigValues creates a logger from level and format strings.
195// This is used by main.go to create a logger from the config package's LoggingConfig
196// without creating an import cycle.
197func NewFromConfigValues(level, format string) zerolog.Logger {
198	return NewFromConfigValuesWithTimeFormat(level, format, "")
199}
200
201// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
202func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
203	cfg := DefaultConfig()
204	cfg.Level = ParseLevel(level)
205	if timeFormat != "" {
206		cfg.TimeFormat = timeFormat
207	}
208
209	switch format {
210	case logFormatJSON:
211		cfg.Format = logFormatJSON
212	case logFormatConsole, logFormatText, "":
213		cfg.Format = logFormatConsole
214	default:
215		cfg.Format = logFormatConsole
216	}
217
218	return New(cfg)
219}
220
221// ParseLevel converts a level string to zerolog.Level
222func ParseLevel(level string) zerolog.Level {
223	switch level {
224	case "trace":
225		return zerolog.TraceLevel
226	case "debug":
227		return zerolog.DebugLevel
228	case "info", "":
229		return zerolog.InfoLevel
230	case "warn":
231		return zerolog.WarnLevel
232	case "error":
233		return zerolog.ErrorLevel
234	case "fatal":
235		return zerolog.FatalLevel
236	default:
237		return zerolog.InfoLevel
238	}
239}
240
241// PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
242const PermissionLogURLMaxLen = 96
243
244// TruncateURL truncates a URL to maxLen characters for logging.
245func TruncateURL(url string, maxLen int) string {
246	if len(url) <= maxLen {
247		return url
248	}
249	return url[:maxLen] + "..."
250}

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 8

Tags 42

↑↓ moveenter openesc close