v0.21.0 ef38ad0

ci_cron.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"fmt"
7	"strconv"
8	"strings"
9	"time"
10
11	// The time zones of schedules do not depend on the host's zoneinfo (the
12	// image has none).
13	_ "time/tzdata"
14)
15
16// Cron is a parsed "cron:" of a schedule trigger: five fields (minute, hour, day
17// of month, month, day of week), matched against a wall clock (UTC, or the
18// "timezone" of the entry: CronSchedule). The syntax is that of GitHub's schedule: "*", numbers, lists
19// "a,b", ranges "a-b", steps "*/n", "a/n" and "a-b/n", the names jan-dec and
20// sun-sat in any case; the day of week is 0-6 (Sunday is 0).
21//
22// As in Vixie cron, when both the day of month and the day of week are
23// restricted (neither starts with "*") a day matches if either does; otherwise
24// both must.
25type Cron struct {
26	minute, hour, dom, month, dow uint64 // bit sets
27	domStar, dowStar              bool
28}
29
30type cronField struct {
31	min, max int
32	names    map[string]int
33}
34
35var (
36	cronMinuteField = cronField{0, 59, nil}
37	cronHourField   = cronField{0, 23, nil}
38	cronDomField    = cronField{1, 31, nil}
39	cronMonthField  = cronField{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}}
40	cronDowField    = cronField{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
41)
42
43// ParseCron parses the five fields of a cron expression separated by spaces.
44func ParseCron(s string) (Cron, error) {
45	parts := strings.Fields(s)
46	if len(parts) != 5 {
47		return Cron{}, fmt.Errorf("%w: cron %q must have 5 fields: minute hour day-of-month month day-of-week", ErrInvalid, s)
48	}
49	var c Cron
50	var err error
51	for i, f := range []struct {
52		spec cronField
53		dst  *uint64
54	}{
55		{cronMinuteField, &c.minute}, {cronHourField, &c.hour}, {cronDomField, &c.dom}, {cronMonthField, &c.month}, {cronDowField, &c.dow},
56	} {
57		if *f.dst, err = parseCronField(parts[i], f.spec); err != nil {
58			return Cron{}, fmt.Errorf("%w: cron %q: %v", ErrInvalid, s, err)
59		}
60	}
61	c.domStar, c.dowStar = strings.HasPrefix(parts[2], "*"), strings.HasPrefix(parts[4], "*")
62	return c, nil
63}
64
65func parseCronField(s string, f cronField) (uint64, error) {
66	var set uint64
67	for _, item := range strings.Split(s, ",") {
68		if item == "" {
69			return 0, fmt.Errorf("empty item in %q", s)
70		}
71		rangePart, stepPart, hasStep := strings.Cut(item, "/")
72		step := 1
73		if hasStep {
74			n, err := strconv.Atoi(stepPart)
75			if err != nil || n < 1 || strings.Trim(stepPart, "0123456789") != "" {
76				return 0, fmt.Errorf("invalid step %q", stepPart)
77			}
78			// A step larger than the field is accepted, as cron does, and keeps
79			// only the first value: it is clamped to the size of the field, which
80			// has the same meaning and cannot overflow the loop below.
81			step = min(n, f.max-f.min+1)
82		}
83		lo, hi := f.min, f.max
84		if rangePart != "*" {
85			a, b, isRange := strings.Cut(rangePart, "-")
86			var err error
87			if lo, err = cronValue(a, f); err != nil {
88				return 0, err
89			}
90			switch {
91			case isRange:
92				if hi, err = cronValue(b, f); err != nil {
93					return 0, err
94				}
95				if hi < lo {
96					return 0, fmt.Errorf("range %q goes backwards", rangePart)
97				}
98			case hasStep:
99				// "a/n" is "a-max/n".
100			default:
101				hi = lo
102			}
103		}
104		for v := lo; v <= hi; v += step {
105			set |= 1 << uint(v)
106			if step > hi-v {
107				break // the next value is past the range (and v+step could overflow)
108			}
109		}
110	}
111	return set, nil
112}
113
114func cronValue(s string, f cronField) (int, error) {
115	if v, ok := f.names[strings.ToLower(s)]; ok {
116		return v, nil
117	}
118	n, err := strconv.Atoi(s)
119	if err != nil || strings.Trim(s, "0123456789") != "" || n < f.min || n > f.max {
120		return 0, fmt.Errorf("invalid value %q (%d-%d)", s, f.min, f.max)
121	}
122	return n, nil
123}
124
125// Matches reports whether the minute of t (in UTC) is one the expression fires
126// at.
127func (c Cron) Matches(t time.Time) bool {
128	t = t.UTC()
129	if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || c.month&(1<<uint(t.Month())) == 0 {
130		return false
131	}
132	domOK := c.dom&(1<<uint(t.Day())) != 0
133	dowOK := c.dow&(1<<uint(t.Weekday())) != 0
134	if !c.domStar && !c.dowStar {
135		return domOK || dowOK
136	}
137	return domOK && dowOK
138}
139
140// CronSchedule is a cron entry with its time zone (CI-4 D6). GitHub runs the
141// entry at the wall clock of the zone, UTC when it has none. In a zone with
142// daylight saving time, an entry whose time is skipped by a spring-forward
143// transition runs at the next valid time (docs.github.com, "Events that trigger
144// workflows", schedule: "a 2:30 AM schedule advances to 3:00 AM"). The docs do
145// not say what a fall-back transition does: a wall clock that happens twice
146// runs once, at its first occurrence, as cron does.
147type CronSchedule struct {
148	Cron Cron
149	Loc  *time.Location
150}
151
152// ParseCronTimezone reads the "timezone" of a schedule entry: an IANA time zone
153// name, UTC when empty. "Local" (the server's zone) is not one.
154func ParseCronTimezone(name string) (*time.Location, error) {
155	if name == "" {
156		return time.UTC, nil
157	}
158	if name == "Local" || strings.HasPrefix(name, "/") || strings.Contains(name, "..") {
159		return nil, fmt.Errorf("%w: invalid schedule timezone %q: expected an IANA time zone name such as America/New_York", ErrInvalid, name)
160	}
161	loc, err := time.LoadLocation(name)
162	if err != nil {
163		return nil, fmt.Errorf("%w: invalid schedule timezone %q: expected an IANA time zone name such as America/New_York", ErrInvalid, name)
164	}
165	return loc, nil
166}
167
168// cronGapLimit bounds how far back a transition is looked for: the largest
169// clock change of the time zone database (Samoa skipped a whole day in 2011).
170const cronGapLimit = 25 * time.Hour
171
172// Due reports whether the entry fires at the minute that starts at t.
173func (s CronSchedule) Due(t time.Time) bool {
174	loc := s.Loc
175	if loc == nil {
176		loc = time.UTC
177	}
178	t = t.Truncate(time.Minute)
179	wall := wallMinute(t, loc)
180	if s.Cron.Matches(wall) {
181		// A wall clock already seen in the last hours (a fall-back
182		// transition repeats it) does not fire again.
183		return !s.repeated(t, wall, loc)
184	}
185	// The first minute after a spring-forward gap runs the entries whose wall
186	// clock fell in the gap.
187	prev := wallMinute(t.Add(-time.Minute), loc)
188	for w := prev.Add(time.Minute); w.Before(wall); w = w.Add(time.Minute) {
189		if s.Cron.Matches(w) {
190			return true
191		}
192	}
193	return false
194}
195
196// repeated reports whether the wall clock of t was already shown earlier: an
197// instant t-d has it when the offset there is the offset at t plus d, so only
198// the larger offsets of the last hours are candidates.
199func (s CronSchedule) repeated(t, wall time.Time, loc *time.Location) bool {
200	if loc == time.UTC {
201		return false
202	}
203	_, now := t.In(loc).Zone()
204	for h := time.Hour; h <= cronGapLimit; h += time.Hour {
205		_, off := t.Add(-h).In(loc).Zone()
206		if back := time.Duration(off-now) * time.Second; back > 0 && wallMinute(t.Add(-back), loc).Equal(wall) {
207			return true
208		}
209	}
210	return false
211}
212
213// wallMinute is the wall clock of t in loc, as a UTC time (Matches reads its
214// fields).
215func wallMinute(t time.Time, loc *time.Location) time.Time {
216	l := t.In(loc)
217	return time.Date(l.Year(), l.Month(), l.Day(), l.Hour(), l.Minute(), 0, 0, time.UTC)
218}

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 44

↑↓ moveenter openesc close