v0.15.0 8a6cfec

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
12// Cron is a parsed "cron:" of a schedule trigger: five fields (minute, hour, day
13// of month, month, day of week), evaluated in UTC (CI-3 D5; the "timezone" key
14// is ignored). The syntax is that of GitHub's schedule: "*", numbers, lists
15// "a,b", ranges "a-b", steps "*/n", "a/n" and "a-b/n", the names jan-dec and
16// sun-sat in any case; the day of week is 0-6 (Sunday is 0).
17//
18// As in Vixie cron, when both the day of month and the day of week are
19// restricted (neither starts with "*") a day matches if either does; otherwise
20// both must.
21type Cron struct {
22	minute, hour, dom, month, dow uint64 // bit sets
23	domStar, dowStar              bool
24}
25
26type cronField struct {
27	min, max int
28	names    map[string]int
29}
30
31var (
32	cronMinuteField = cronField{0, 59, nil}
33	cronHourField   = cronField{0, 23, nil}
34	cronDomField    = cronField{1, 31, nil}
35	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}}
36	cronDowField    = cronField{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
37)
38
39// ParseCron parses the five fields of a cron expression separated by spaces.
40func ParseCron(s string) (Cron, error) {
41	parts := strings.Fields(s)
42	if len(parts) != 5 {
43		return Cron{}, fmt.Errorf("%w: cron %q must have 5 fields: minute hour day-of-month month day-of-week", ErrInvalid, s)
44	}
45	var c Cron
46	var err error
47	for i, f := range []struct {
48		spec cronField
49		dst  *uint64
50	}{
51		{cronMinuteField, &c.minute}, {cronHourField, &c.hour}, {cronDomField, &c.dom}, {cronMonthField, &c.month}, {cronDowField, &c.dow},
52	} {
53		if *f.dst, err = parseCronField(parts[i], f.spec); err != nil {
54			return Cron{}, fmt.Errorf("%w: cron %q: %v", ErrInvalid, s, err)
55		}
56	}
57	c.domStar, c.dowStar = strings.HasPrefix(parts[2], "*"), strings.HasPrefix(parts[4], "*")
58	return c, nil
59}
60
61func parseCronField(s string, f cronField) (uint64, error) {
62	var set uint64
63	for _, item := range strings.Split(s, ",") {
64		if item == "" {
65			return 0, fmt.Errorf("empty item in %q", s)
66		}
67		rangePart, stepPart, hasStep := strings.Cut(item, "/")
68		step := 1
69		if hasStep {
70			n, err := strconv.Atoi(stepPart)
71			if err != nil || n < 1 || strings.Trim(stepPart, "0123456789") != "" {
72				return 0, fmt.Errorf("invalid step %q", stepPart)
73			}
74			// A step larger than the field is accepted, as cron does, and keeps
75			// only the first value: it is clamped to the size of the field, which
76			// has the same meaning and cannot overflow the loop below.
77			step = min(n, f.max-f.min+1)
78		}
79		lo, hi := f.min, f.max
80		if rangePart != "*" {
81			a, b, isRange := strings.Cut(rangePart, "-")
82			var err error
83			if lo, err = cronValue(a, f); err != nil {
84				return 0, err
85			}
86			switch {
87			case isRange:
88				if hi, err = cronValue(b, f); err != nil {
89					return 0, err
90				}
91				if hi < lo {
92					return 0, fmt.Errorf("range %q goes backwards", rangePart)
93				}
94			case hasStep:
95				// "a/n" is "a-max/n".
96			default:
97				hi = lo
98			}
99		}
100		for v := lo; v <= hi; v += step {
101			set |= 1 << uint(v)
102			if step > hi-v {
103				break // the next value is past the range (and v+step could overflow)
104			}
105		}
106	}
107	return set, nil
108}
109
110func cronValue(s string, f cronField) (int, error) {
111	if v, ok := f.names[strings.ToLower(s)]; ok {
112		return v, nil
113	}
114	n, err := strconv.Atoi(s)
115	if err != nil || strings.Trim(s, "0123456789") != "" || n < f.min || n > f.max {
116		return 0, fmt.Errorf("invalid value %q (%d-%d)", s, f.min, f.max)
117	}
118	return n, nil
119}
120
121// Matches reports whether the minute of t (in UTC) is one the expression fires
122// at.
123func (c Cron) Matches(t time.Time) bool {
124	t = t.UTC()
125	if c.minute&(1<<uint(t.Minute())) == 0 || c.hour&(1<<uint(t.Hour())) == 0 || c.month&(1<<uint(t.Month())) == 0 {
126		return false
127	}
128	domOK := c.dom&(1<<uint(t.Day())) != 0
129	dowOK := c.dow&(1<<uint(t.Weekday())) != 0
130	if !c.domStar && !c.dowStar {
131		return domOK || dowOK
132	}
133	return domOK && dowOK
134}

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