diff --git a/free/ci/internal/providers/github/reconcile/scan_conditional_test.go b/free/ci/internal/providers/github/reconcile/scan_conditional_test.go new file mode 100644 index 000000000..e668d1c08 --- /dev/null +++ b/free/ci/internal/providers/github/reconcile/scan_conditional_test.go @@ -0,0 +1,58 @@ +package reconcile + +// Purpose: P7-AUTO-11 conditional requests on the head scan: once a full scan has +// stored its ETags, an unchanged repository answers 304 on the repository, the pull +// list and (with extra branches) the branch list, and ScanHeads makes no further +// request; a changed pull list is read in full again. +// Inputs: the HOST-17 fixture forge and engine rig. Outputs: pass or fail. +// Constraints: loopback TLS only; synthetic names and shas. + +import ( + "context" + "testing" +) + +func TestScanHeadsConditional(t *testing.T) { + ctx := context.Background() + r := newRig(t, "o/r") + r.f.do(func() { r.f.repos["o/r"].Pulls = []fPull{{Number: 7, Head: sha('b', 7), Assoc: "OWNER"}} }) + opts := []ScanOptions{{}, {Branches: []string{"release/*"}, MaxBranches: 5}} + for _, o := range opts { + first, err := r.e.ScanHeads(ctx, "o/r", o) + if err != nil || first.NotModified || len(first.Heads) == 0 { + t.Fatalf("the first scan must list the heads: %+v %v", first, err) + } + r.f.reset() + again, err := r.e.ScanHeads(ctx, "o/r", o) + if err != nil || !again.NotModified || len(again.Heads) != 0 { + t.Fatalf("an unchanged repository must answer NotModified: %+v %v", again, err) + } + want := 2 // the repository and the pull list + if len(o.Branches) > 0 { + want = 3 // and the branch list + } + if n := r.f.total(); n != want || r.f.nonCond304() != 0 || again.Calls != want { + t.Fatalf("a 304 pass must make exactly %d conditional requests: total=%d non-304=%d calls=%d", want, n, r.f.nonCond304(), again.Calls) + } + if r.f.count(func(q reqRec) bool { return !q.Cond }) != 0 { + t.Fatal("every repeated read must be conditional") + } + } + + // A new pull request changes the list: the scan reads it and reports the new head. + r.f.do(func() { + r.f.repos["o/r"].Pulls = append(r.f.repos["o/r"].Pulls, fPull{Number: 8, Head: sha('c', 8), Assoc: "OWNER"}) + }) + r.f.reset() + changed, err := r.e.ScanHeads(ctx, "o/r", ScanOptions{}) + if err != nil || changed.NotModified { + t.Fatalf("a changed pull list must not be NotModified: %+v %v", changed, err) + } + found := false + for _, h := range changed.Heads { + found = found || h.PR == 8 + } + if !found { + t.Fatalf("the new pull request head is missing: %+v", changed.Heads) + } +} diff --git a/free/ci/internal/providers/github/reconcile/scan_export.go b/free/ci/internal/providers/github/reconcile/scan_export.go index 2635f1300..a5a414b35 100644 --- a/free/ci/internal/providers/github/reconcile/scan_export.go +++ b/free/ci/internal/providers/github/reconcile/scan_export.go @@ -62,8 +62,8 @@ type ghCommit struct { } // ScanHeads lists the candidate heads of repo for the watcher. -func (e *Engine) ScanHeads(ctx context.Context, repo string, o ScanOptions) (HeadScan, error) { - var res HeadScan +// The result is named so the deferred call count reaches the caller on every return. +func (e *Engine) ScanHeads(ctx context.Context, repo string, o ScanOptions) (res HeadScan, err error) { s, err := e.session(ctx, kindScan, e.passLimit()) if err != nil { return res, err diff --git a/free/ci/internal/watch/conditional_test.go b/free/ci/internal/watch/conditional_test.go new file mode 100644 index 000000000..e57db322b --- /dev/null +++ b/free/ci/internal/watch/conditional_test.go @@ -0,0 +1,80 @@ +package watch + +// Purpose: P7-AUTO-11 conditional requests on the watcher side: a scan whose +// conditional reads all answered 304 keeps the previous heads (no fresh listing +// is needed to act on them), a 304 before any listing acts on nothing, and the +// passes of two hosts are offset and jittered apart. +// Inputs: the shared rig (fake ledger, scripted runner) and a scripted scanner. +// Outputs: pass or fail. Constraints: no network, no sleeps. + +import ( + "context" + "testing" + "time" +) + +// notModScan answers the first scan with heads and every later one with 304. +type notModScan struct { + heads []Head + calls int +} + +func (s *notModScan) Scan(context.Context, string) (Scan, error) { + s.calls++ + if s.calls == 1 { + return Scan{Heads: append([]Head(nil), s.heads...)}, nil + } + return Scan{NotModified: true}, nil +} + +func TestConditionalRequests(t *testing.T) { + ctx := context.Background() + + // A 304 keeps the heads of the last full scan: the pass still sees them (here: already final). + r := newRig(t, ownerHead("o/r", 1)) + scan := ¬ModScan{heads: r.scan.heads} + r.w.P.Scanner = scan + if rep := r.w.Pass(ctx); rep.Ran != 1 { + t.Fatalf("the first pass must run the head: %+v", rep) + } + rep := r.w.Pass(ctx) + if scan.calls != 2 || len(rep.Decisions) != 1 || rep.Decisions[0].SHA != sha(1) || rep.Ran != 0 { + t.Fatalf("a 304 scan must keep the previous heads and run nothing new: calls=%d %+v", scan.calls, rep) + } + if r.runner.count() != 1 { + t.Fatalf("a 304 scan must not rerun a head: %d runs", r.runner.count()) + } + + // A 304 before any full scan has nothing to act on. + fresh := newRig(t) + fresh.w.P.Scanner = ¬ModScan{calls: 1} + if rep := fresh.w.Pass(ctx); len(rep.Decisions) != 0 || rep.Ran != 0 { + t.Fatalf("a 304 without a previous scan must act on nothing: %+v", rep) + } + + // Two hosts poll out of step: distinct phase offsets inside one interval, and a + // jittered wait within [0.9, 1.1) of the interval. + c := Defaults() + c.Interval = time.Minute + mk := func(host string, jitter float64) *Watcher { + c.Host = host + w, err := New(c, Ports{Scanner: scan, Ledger: r.gh.on(host), Reporter: r.gh.on(host), Runner: r.runner, Jitter: func() float64 { return jitter }}) + if err != nil { + t.Fatal(err) + } + return w + } + a, b := mk("mini1", 0), mk("mini2", 0.999) + if a.Offset() == b.Offset() { + t.Fatalf("two hosts must not share a phase offset: %v", a.Offset()) + } + for _, w := range []*Watcher{a, b} { + if o := w.Offset(); o < 0 || o >= c.Interval { + t.Fatalf("offset %v is outside one interval", o) + } + } + now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) + if lo, hi := a.Next(now), b.Next(now); lo != 54*time.Second || hi < lo || hi >= 66*time.Second { + t.Fatalf("jittered waits %v and %v must lie in [0.9, 1.1) of the interval", lo, hi) + } +}