package api
import (
"bytes"
"errors"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
// syncedMove is a required-checks policy that, the first time it is
// asked, pushes to feat AND folds that push into the index, so the
// index tip has moved between the check and the merge.
type syncedMove struct {
once sync.Once
move func()
required []string
}
func (m *syncedMove) RequiredChecks(string, string) []string {
m.once.Do(m.move)
return m.required
}
func (m *syncedMove) PremergeMode(string, string) bool { return false }
// runsAt answers the runs recorded for exactly one check-in each.
type runsAt map[string][]CIRun
func (r runsAt) CIRuns(string, int) ([]CIRun, error) { return nil, nil }
func (r runsAt) CIRunsPage(string, int, int) ([]CIRun, error) { return nil, nil }
func (r runsAt) CIRunsFor(_ string, c string) ([]CIRun, error) {
return r[c], nil
}
func (r runsAt) CIRunsForPair(string, string) ([]CIRun, error) { return nil, nil }
// acceptAfterAMove accepts the landing with checks that move feat, and
// the index, once they are consulted. It answers the tip checked, the
// tip moved to, the accept's error and the --expect-tip zit was given.
func acceptAfterAMove(t *testing.T, required []string, runs func(checked, moved string) runsAt) (checked, moved, expectTip string, err error) {
t.Helper()
dir, id, ix, pipeSync := acceptStore(t)
real, lerr := exec.LookPath("zit")
if lerr != nil {
t.Skip("zit not on PATH")
}
argv := filepath.Join(t.TempDir(), "argv")
bin := t.TempDir()
script := "#!/bin/sh\nif [ \"$1\" = landing ] && [ \"$2\" = accept ]; then printf '%s\\n' \"$@\" > '" +
argv + "'; fi\nexec '" + real + "' \"$@\"\n"
if werr := os.WriteFile(filepath.Join(bin, "zit"), []byte(script), 0o755); werr != nil {
t.Fatal(werr)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
checked = ztip(t, dir, "feat")
checks := &syncedMove{required: required, move: func() {
zwrite(t, dir, "h.txt", "unchecked\n")
zrun(t, dir, "add", "h.txt")
zrun(t, dir, "commit", "-m", "late push", "--user", "u")
moved = ztip(t, dir, "feat")
if _, serr := pipeSync(); serr != nil {
t.Fatal(serr)
}
}}
deps := Deps{Version: "v", Checks: checks, Repos: NewRepos(map[string]*Repo{
"test": {Index: ix, Zit: zitcli.New(dir), Sync: pipeSync}})}
if runs != nil {
// The runs depend on the tips, and moved is known only once the
// checks are consulted: resolve lazily through the map.
ra := runsAt{}
deps.CI = ra
orig := checks.move
checks.move = func() {
orig()
for k, v := range runs(checked, moved) {
ra[k] = v
}
}
}
r := ops.NewRegistry()
if rerr := RegisterAll(r, deps); rerr != nil {
t.Fatal(rerr)
}
_, err = r.Invoke(as("dan", "i"), "landing.accept", landingSayIn{Repo: "test", ID: id})
if moved == "" || moved == checked {
t.Fatalf("the test never moved the branch: checked %s, now %s", checked, moved)
}
if b, rerr := os.ReadFile(argv); rerr == nil {
args := strings.Split(strings.TrimSpace(string(b)), "\n")
for i := 0; i+1 < len(args); i++ {
if args[i] == "--expect-tip" {
expectTip = args[i+1]
}
}
}
return checked, moved, expectTip, err
}
func assertBranchMoved(t *testing.T, err error, checked, moved string) {
t.Helper()
var ref *ops.Refusal
if !errors.As(err, &ref) || ref.Code != RefusalBranchMoved {
t.Fatalf("FAIL: accept = %v; want a %s refusal", err, RefusalBranchMoved)
}
if ref.Fields["expected"] != checked || ref.Fields["current"] != moved {
t.Fatalf("FAIL: refusal fields %v; want expected %s, current %s", ref.Fields, checked, moved)
}
}
// The index can catch up with a push between the checks and the merge.
// zit is still handed the tip the checks judged, never a fresh read.
func TestAcceptHandsZitTheCheckedTipEvenWhenTheIndexMoved(t *testing.T) {
checked, moved, expectTip, err := acceptAfterAMove(t, nil, nil)
if expectTip != checked {
t.Fatalf("FAIL: zit was given --expect-tip %q; want the checked tip %s (moved is %s)", expectTip, checked, moved)
}
assertBranchMoved(t, err, checked, moved)
t.Logf("ok: %v", err)
}
// Green at the checked tip, then moved: the checks pass because they
// judged the checked tip, and zit refuses the moved branch.
func TestChecksGreenAtTheCheckedTipStillRefuseAMovedBranch(t *testing.T) {
checked, moved, _, err := acceptAfterAMove(t, []string{"ci"}, func(checked, _ string) runsAt {
return runsAt{checked: {{Checkin: checked, Workflow: "ci", Pipeline: "ci", Status: "success"}}}
})
assertBranchMoved(t, err, checked, moved)
t.Logf("ok: %v", err)
}
// Green only at the new tip: the tip being accepted was never checked,
// so the accept is refused as unmet before zit is asked.
func TestChecksGreenOnlyAtTheNewTipAreUnmetAtTheCheckedOne(t *testing.T) {
_, _, expectTip, err := acceptAfterAMove(t, []string{"ci"}, func(_, moved string) runsAt {
return runsAt{moved: {{Checkin: moved, Workflow: "ci", Pipeline: "ci", Status: "success"}}}
})
if !errors.Is(err, ops.ErrForbidden) || !strings.Contains(err.Error(), "required checks not green") {
t.Fatalf("FAIL: accept = %v; want refused as unmet required checks", err)
}
if expectTip != "" {
t.Fatalf("FAIL: zit was asked to accept (--expect-tip %s) over unmet checks", expectTip)
}
t.Logf("ok: %v", err)
}
type sweepStub struct {
rows []oltp.AutoAccept
waiting []string
}
func (s *sweepStub) AutoAccepts() ([]oltp.AutoAccept, error) { return s.rows, nil }
func (s *sweepStub) ClearAutoAccept(string, string) error { return nil }
func (s *sweepStub) SetAutoWaiting(_, _, reason string) { s.waiting = append(s.waiting, reason) }
// On a zit too old to guard an accept, the sweep tries nothing and says
// so at most once, not once per armed landing per pass.
func TestTheSweepOnAnOldZitIsQuiet(t *testing.T) {
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
d := Deps{Version: "v", ZitVersion: "0.3.30", Repos: NewRepos(map[string]*Repo{})}
store := &sweepStub{rows: []oltp.AutoAccept{{Repo: "a", Landing: "L1", Account: "dan"}, {Repo: "b", Landing: "L2", Account: "dan"}}}
for range 3 {
if landed := d.AutoAcceptSweep(store); len(landed) != 0 {
t.Fatalf("FAIL: the sweep landed %v on an old zit", landed)
}
}
out := buf.String()
if strings.Contains(out, "auto-land") {
t.Fatalf("FAIL: the sweep logged per landing per pass:\n%s", out)
}
if n := strings.Count(out, zitcli.MinVersion); n > 1 {
t.Fatalf("FAIL: the too-old line was logged %d times:\n%s", n, out)
}
if len(store.waiting) == 0 || !strings.Contains(store.waiting[0], zitcli.MinVersion) {
t.Fatalf("FAIL: an armed landing's page is not told why it waits: %v", store.waiting)
}
t.Logf("ok: %d waiting notes, log %q", len(store.waiting), out)
}