1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "sync"
7 "testing"
8 "time"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/usecase"
18)
19
20// CI-3 D5, AC5: schedule, with an injected clock.
21
22// testClock is a clock the test moves.
23type testClock struct {
24 mu sync.Mutex
25 t time.Time
26}
27
28func (c *testClock) Now() time.Time {
29 c.mu.Lock()
30 defer c.mu.Unlock()
31 return c.t
32}
33
34func (c *testClock) set(s string) {
35 c.mu.Lock()
36 defer c.mu.Unlock()
37 t, err := time.Parse("2006-01-02 15:04:05", s)
38 if err != nil {
39 panic(err)
40 }
41 c.t = t
42}
43
44func scheduleWorkflow(crons ...string) map[string]string {
45 on := "on:\n schedule:\n"
46 for _, c := range crons {
47 on += " - cron: '" + c + "'\n"
48 }
49 return map[string]string{".github/workflows/nightly.yml": "name: nightly\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}
50}
51
52func newScheduleHarness(t *testing.T, start string) (*ciHarness, *testClock, *fakeRunner) {
53 t.Helper()
54 clk := &testClock{}
55 clk.set(start)
56 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
57 cfg.Clock = clk
58 cfg.ScheduleTick = time.Hour // the tests call ScheduleNow
59 })
60 return c, clk, c.registerRunner("alpha", "ubuntu-latest")
61}
62
63func createRepoParams(name domain.RepoName) ports.CreateRepoParams {
64 return ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate}
65}
66
67func (c *ciHarness) scheduled() []*pb.Run { return scheduleRuns(c.runsOf("nightly")) }
68
69func scheduleRuns(runs []*pb.Run) []*pb.Run {
70 var out []*pb.Run
71 for _, r := range runs {
72 if r.GetEvent() == "schedule" {
73 out = append(out, r)
74 }
75 }
76 return out
77}
78
79func TestScheduleFiresOncePerMatchingMinuteWithoutCatchUp(t *testing.T) {
80 c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
81 c.push("main", scheduleWorkflow("*/5 * * * *"), "")
82 ci := c.forge.CI
83 tick := func(at string) {
84 clk.set(at)
85 ci.ScheduleNow(c.ctx)
86 }
87
88 tick("2026-03-04 10:00:40")
89 assert.Empty(t, c.scheduled(), "the minute ermite started in belongs to the process before it")
90
91 tick("2026-03-04 10:05:10")
92 require.Len(t, c.scheduled(), 1)
93 run := c.scheduled()[0]
94 assert.Equal(t, "schedule", run.GetEvent())
95 assert.Equal(t, "refs/heads/main", run.GetRef())
96 assert.Equal(t, c.ref("refs/heads/main"), run.GetSha(), "the latest commit of the default branch")
97 assert.Equal(t, "root", run.GetActor(), "whoever pushed the tip (the root token here)")
98 assert.True(t, run.GetTrusted())
99
100 tick("2026-03-04 10:05:50")
101 tick("2026-03-04 10:05:59")
102 assert.Len(t, c.scheduled(), 1, "a minute fires once, however many ticks it holds")
103 tick("2026-03-04 10:06:00")
104 tick("2026-03-04 10:09:00")
105 assert.Len(t, c.scheduled(), 1, "minutes that do not match fire nothing")
106
107 // ermite was busy or down for the minutes 10:10 and 10:15: they are not made up.
108 tick("2026-03-04 10:20:00")
109 assert.Len(t, c.scheduled(), 2, "only the minute the clock reached fires, not the ones skipped")
110 assert.Equal(t, time.Date(2026, 3, 4, 10, 20, 0, 0, time.UTC), ci.SchedulePassed())
111
112 job := runner.await()
113 gh := c.ghContext(job)
114 assert.Equal(t, "schedule", gh["event_name"])
115 assert.Equal(t, "refs/heads/main", gh["ref"])
116 assert.Equal(t, "*/5 * * * *", gh["event"].(map[string]any)["schedule"], "the payload is the cron entry")
117 assert.True(t, job.GetTrusted())
118}
119
120// Several cron entries of one workflow that match the same minute start one
121// run, with the first matching entry as payload; an entry that matches another
122// minute starts its own.
123func TestScheduleSeveralMatchingCronsOfOneWorkflowStartOneRun(t *testing.T) {
124 c, clk, runner := newScheduleHarness(t, "2026-03-04 10:00:30")
125 c.push("main", scheduleWorkflow("*/2 * * * *", "* * * * *", "7 10 * * *"), "")
126 ci := c.forge.CI
127
128 clk.set("2026-03-04 10:02:00") // the first and the second entries match
129 ci.ScheduleNow(c.ctx)
130 require.Len(t, c.scheduled(), 1, "one run for the workflow, not one per entry")
131 job := runner.await()
132 assert.Equal(t, "*/2 * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"], "the first entry that matches")
133 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
134
135 clk.set("2026-03-04 10:03:00") // only the second
136 ci.ScheduleNow(c.ctx)
137 require.Len(t, c.scheduled(), 2)
138 job = runner.await()
139 assert.Equal(t, "* * * * *", c.ghContext(job)["event"].(map[string]any)["schedule"])
140 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
141}
142
143func TestScheduleReadsAgainAfterAPushToTheDefaultBranch(t *testing.T) {
144 c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
145 c.push("main", scheduleWorkflow("*/5 * * * *"), "")
146 ci := c.forge.CI
147 tick := func(at string) {
148 clk.set(at)
149 ci.ScheduleNow(c.ctx)
150 }
151 tick("2026-03-04 10:05:00")
152 require.Len(t, c.scheduled(), 1)
153 assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the schedules are cached")
154
155 // 10:07 matches no */5 entry; the new file fires every minute.
156 c.push("main", scheduleWorkflow("* * * * *"), "")
157 tick("2026-03-04 10:06:00")
158 require.Len(t, c.scheduled(), 2, "the pushed workflow is the one scheduled")
159 assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
160
161 // A push to another branch changes nothing; a push that removes the schedule
162 // ends it.
163 c.mustGit(c.wt, "checkout", "-q", "-b", "side")
164 c.push("side", scheduleWorkflow("0 0 1 1 *"), "")
165 tick("2026-03-04 10:07:00")
166 require.Len(t, c.scheduled(), 3, "the default branch's workflow still fires every minute")
167
168 c.mustGit(c.wt, "checkout", "-q", "main")
169 c.push("main", map[string]string{".github/workflows/nightly.yml": "name: nightly\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
170 tick("2026-03-04 10:08:00")
171 c.waitFor("the push run of the new file", func() bool { return len(c.runs()) == 4 })
172 assert.Len(t, scheduleRuns(c.runs()), 3, "no schedule left on the default branch")
173 assert.Equal(t, 0, ci.CachedSchedules(c.name("proj")))
174}
175
176func TestScheduleIsRemovedWithTheRepository(t *testing.T) {
177 c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
178 c.push("main", scheduleWorkflow("* * * * *"), "")
179 ci := c.forge.CI
180 clk.set("2026-03-04 10:01:00")
181 ci.ScheduleNow(c.ctx)
182 require.Len(t, c.scheduled(), 1)
183 require.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
184
185 _, err := c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
186 require.NoError(t, err)
187 assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "the cached schedules go with the repository")
188
189 for _, at := range []string{"2026-03-04 10:02:00", "2026-03-04 10:03:00"} {
190 clk.set(at)
191 ci.ScheduleNow(c.ctx)
192 }
193 assert.Equal(t, -1, ci.CachedSchedules(c.name("proj")), "never scheduled again")
194
195 // The same name created again starts empty: no schedule until it has a workflow.
196 _, err = c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name("proj")))
197 require.NoError(t, err)
198 clk.set("2026-03-04 10:04:00")
199 ci.ScheduleNow(c.ctx)
200 assert.Empty(t, c.scheduled())
201}
202
203// More repositories than one tick can handle, and a new minute at every tick:
204// each pass is dropped after its first repository, and the cursor makes the next
205// one start after it, so that none is starved.
206func TestScheduleDoesNotStarveRepositoriesBeyondTheBudget(t *testing.T) {
207 clk := &testClock{}
208 clk.set("2026-03-04 10:00:30")
209 visited := map[domain.RepoName]bool{}
210 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
211 cfg.Clock = clk
212 cfg.ScheduleTick = time.Hour
213 cfg.ScheduleBudget = 1
214 cfg.ScheduleVisit = func(r domain.RepoName) { visited[r] = true }
215 })
216 c.registerRunner("alpha", "ubuntu-latest")
217 names := []domain.RepoName{c.name("proj")}
218 for _, n := range []string{"aa", "zz", "mm", "bb"} {
219 names = append(names, c.name(n))
220 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(n)))
221 require.NoError(t, err)
222 }
223 ci := c.forge.CI
224 seen := func() int { return len(visited) }
225 minutes := []string{"10:01:00", "10:02:00", "10:03:00", "10:04:00", "10:05:00"}
226 for i, m := range minutes {
227 clk.set("2026-03-04 " + m)
228 ci.ScheduleNow(c.ctx) // one repository, then the next minute drops the pass
229 assert.Equal(t, i+1, seen(), "each minute reaches a repository no earlier minute reached")
230 }
231 assert.Equal(t, len(names), seen(), "every repository was processed")
232}
233
234func TestScheduleBoundsTheWorkPerTick(t *testing.T) {
235 clk := &testClock{}
236 clk.set("2026-03-04 10:00:30")
237 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
238 cfg.Clock = clk
239 cfg.ScheduleTick = time.Hour
240 cfg.ScheduleBudget = 1
241 })
242 c.registerRunner("alpha", "ubuntu-latest")
243 c.push("main", scheduleWorkflow("* * * * *"), "")
244 for _, name := range []string{"two", "three"} {
245 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createRepoParams(c.name(name)))
246 require.NoError(t, err)
247 }
248 ci := c.forge.CI
249 clk.set("2026-03-04 10:01:00")
250 ci.ScheduleNow(c.ctx)
251 assert.True(t, ci.SchedulePassed().IsZero(), "one repository per tick: the pass is not over")
252 ci.ScheduleNow(c.ctx)
253 assert.True(t, ci.SchedulePassed().IsZero())
254 ci.ScheduleNow(c.ctx)
255 assert.Equal(t, time.Date(2026, 3, 4, 10, 1, 0, 0, time.UTC), ci.SchedulePassed(), "three ticks go through three repositories")
256 assert.Len(t, c.scheduled(), 1)
257}