Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions internal/fleetreconcile/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"sort"
Expand All @@ -22,13 +23,18 @@ import (
)

func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
code, err := run(os.Args[1:], os.Stdout)
if err != nil {
fmt.Fprintf(os.Stderr, "fleet-reconcile: %v\n", err)
os.Exit(2) // 2 = tool error (bad input); 1 = gate failed; 0 = gate passed
}
os.Exit(code)
}

func run(args []string, out *os.File) error {
// run executes the reconcile gate and returns the process exit code:
// 0 = gate passed, 1 = gate failed, 2 = tool error (bad input). os.Exit
// lives only in main so tests can assert the code and capture the report
// through any io.Writer.
func run(args []string, out io.Writer) (int, error) {
fs := flag.NewFlagSet("fleet-reconcile", flag.ContinueOnError)
ledgerPath := fs.String("ledger", "", "path to the run-ledger JSONL file (empty = no registered runs)")
runsPath := fs.String("runs", "", "path to a pre-fetched gh-run-list JSON array (mutually exclusive with --window-start)")
Expand All @@ -39,19 +45,19 @@ func run(args []string, out *os.File) error {
selfRunID := fs.Int64("self-run-id", 0, "this reconcile run's own id, excluded from reconciliation")
allow := fs.String("allow-workflows", "", "comma-separated workflow names to reconcile; empty = all")
if err := fs.Parse(args); err != nil {
return err
return 2, err
}
if (*runsPath == "") == (*windowStart == "") {
return fmt.Errorf("exactly one of --runs or --window-start is required")
return 2, fmt.Errorf("exactly one of --runs or --window-start is required")
}

runs, err := loadRuns(*runsPath, *windowStart, *repo, *pageSize, *maxPages)
if err != nil {
return fmt.Errorf("loading runs: %w", err)
return 2, fmt.Errorf("loading runs: %w", err)
}
ledger, err := readLedger(*ledgerPath)
if err != nil {
return fmt.Errorf("reading ledger: %w", err)
return 2, fmt.Errorf("reading ledger: %w", err)
}

opts := fleetreconcile.Options{SelfRunID: *selfRunID}
Expand All @@ -64,12 +70,12 @@ func run(args []string, out *os.File) error {

rep := fleetreconcile.Reconcile(runs, ledger, opts)
if _, err := fmt.Fprint(out, fleetreconcile.FormatReport(rep)); err != nil {
return fmt.Errorf("writing report: %w", err)
return 2, fmt.Errorf("writing report: %w", err)
}
if !rep.Passed() {
os.Exit(1)
return 1, nil
}
return nil
return 0, nil
}

// loadRuns returns the runs to reconcile: either a pre-fetched JSON array
Expand Down Expand Up @@ -202,9 +208,14 @@ func readLedger(path string) ([]fleetreconcile.LedgerEntry, error) {
return nil, err
}
defer func() { _ = f.Close() }()
return parseLedger(f)
}

// parseLedger decodes JSONL ledger content: one JSON LedgerEntry per non-blank
// line. Malformed lines error with their physical (1-based) line number.
func parseLedger(r io.Reader) ([]fleetreconcile.LedgerEntry, error) {
var entries []fleetreconcile.LedgerEntry
sc := bufio.NewScanner(f)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
for sc.Scan() {
Expand Down
242 changes: 242 additions & 0 deletions internal/fleetreconcile/cmd/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stablekernel/cascade/internal/fleetreconcile"
Expand Down Expand Up @@ -88,3 +92,241 @@ func TestSlicePage_SortsNewestFirst(t *testing.T) {
}
}
}

// writeFile writes content to a file under t.TempDir() and returns its path.
func writeFile(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("writing %s: %v", name, err)
}
return path
}

// runsJSON is a two-run window: an unregistered success (benign) and a
// completed failure whose accounting depends on the ledger under test.
const runsJSON = `[
{"databaseId": 11, "workflowName": "e2e", "event": "push", "conclusion": "success", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:00:00Z"},
{"databaseId": 12, "workflowName": "e2e", "event": "push", "conclusion": "failure", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:01:00Z"}
]`

func TestRun_FlagValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "both runs and window-start",
args: []string{"--runs", "runs.json", "--window-start", "2026-07-15T00:00:00Z"},
wantErr: "exactly one of --runs or --window-start is required",
},
{
name: "neither runs nor window-start",
args: nil,
wantErr: "exactly one of --runs or --window-start is required",
},
{
name: "unknown flag",
args: []string{"--bogus"},
wantErr: "flag provided but not defined: -bogus",
},
{
name: "window-start without repo",
args: []string{"--window-start", "2026-07-15T00:00:00Z"},
wantErr: "--repo is required with --window-start",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out bytes.Buffer
code, err := run(tt.args, &out)
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("err = %v, want it to contain %q", err, tt.wantErr)
}
if out.Len() != 0 {
t.Errorf("report written on a validation error: %q", out.String())
}
})
}
}

func TestRun_InputErrors(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.json")
malformedRuns := writeFile(t, "runs.json", "not json")
goodRuns := writeFile(t, "runs.json", runsJSON)
badLedger := writeFile(t, "ledger.jsonl",
"{\"run_id\": 12, \"expected\": \"failure\", \"reason\": \"gated\"}\n\nnot json\n")

tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing runs file",
args: []string{"--runs", missing},
wantErr: "loading runs:",
},
{
name: "malformed runs JSON",
args: []string{"--runs", malformedRuns},
wantErr: "parsing run-list JSON",
},
{
name: "malformed ledger line reports its line number",
args: []string{"--runs", goodRuns, "--ledger", badLedger},
wantErr: "reading ledger: ledger line 3:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out bytes.Buffer
code, err := run(tt.args, &out)
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("err = %v, want it to contain %q", err, tt.wantErr)
}
})
}
}

func TestRun_GatePass(t *testing.T) {
runs := writeFile(t, "runs.json", runsJSON)
ledger := writeFile(t, "ledger.jsonl",
"{\"run_id\": 12, \"expected\": \"failure\", \"reason\": \"negative scenario\"}\n")

var out bytes.Buffer
code, err := run([]string{"--runs", runs, "--ledger", ledger}, &out)
if err != nil {
t.Fatalf("run() error = %v, want nil", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
report := out.String()
if !strings.Contains(report, "RESULT: PASS") {
t.Errorf("report missing PASS result:\n%s", report)
}
if !strings.Contains(report, "FAILING - coverage gaps (0):") {
t.Errorf("report should list zero coverage gaps:\n%s", report)
}
}

func TestRun_GateFail(t *testing.T) {
// Run 12 is a completed failure and nothing registers it: the exact
// fire-and-forget gap the gate exists to catch.
runs := writeFile(t, "runs.json", runsJSON)

var out bytes.Buffer
code, err := run([]string{"--runs", runs}, &out)
if err != nil {
t.Fatalf("run() error = %v, want nil (gate failure is an exit code, not an error)", err)
}
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
report := out.String()
if !strings.Contains(report, "RESULT: FAIL") {
t.Errorf("report missing FAIL result:\n%s", report)
}
if !strings.Contains(report, "run 12") {
t.Errorf("report should name the unaccounted run 12:\n%s", report)
}
}

func TestRun_EmptyAndMissingLedger(t *testing.T) {
// A success-only window passes with no ledger at all: an omitted --ledger
// flag and a --ledger path that does not exist both mean "no registered
// runs".
successOnly := writeFile(t, "runs.json",
`[{"databaseId": 11, "workflowName": "e2e", "event": "push", "conclusion": "success", "status": "completed", "headBranch": "main", "createdAt": "2026-07-15T01:00:00Z"}]`)
missingLedger := filepath.Join(t.TempDir(), "no-ledger.jsonl")

for _, tt := range []struct {
name string
args []string
}{
{name: "omitted ledger flag", args: []string{"--runs", successOnly}},
{name: "missing ledger file", args: []string{"--runs", successOnly, "--ledger", missingLedger}},
} {
t.Run(tt.name, func(t *testing.T) {
var out bytes.Buffer
code, err := run(tt.args, &out)
if err != nil {
t.Fatalf("run() error = %v, want nil", err)
}
if code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(out.String(), "RESULT: PASS") {
t.Errorf("report missing PASS result:\n%s", out.String())
}
})
}
}

func TestParseLedger(t *testing.T) {
tests := []struct {
name string
input string
wantEntries int
wantErr string
}{
{
name: "valid entries with blank lines",
input: "{\"run_id\": 1, \"expected\": \"success\", \"reason\": \"a\"}\n\n{\"run_id\": 2, \"expected\": \"failure\", \"reason\": \"b\"}\n",
wantEntries: 2,
},
{
name: "empty input",
input: "",
},
{
name: "whitespace-only input",
input: "\n \n\t\n",
},
{
name: "malformed line numbered physically",
input: "{\"run_id\": 1, \"expected\": \"success\", \"reason\": \"a\"}\n\n{broken\n",
wantErr: "ledger line 3:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entries, err := parseLedger(strings.NewReader(tt.input))
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want it to contain %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("parseLedger() error = %v", err)
}
if len(entries) != tt.wantEntries {
t.Fatalf("got %d entries, want %d", len(entries), tt.wantEntries)
}
})
}

t.Run("fields decode", func(t *testing.T) {
entries, err := parseLedger(strings.NewReader(
"{\"run_id\": 42, \"expected\": \"failure\", \"reason\": \"hotfix negative\"}\n"))
if err != nil {
t.Fatalf("parseLedger() error = %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
e := entries[0]
if e.RunID != 42 || e.Expected != "failure" || e.Reason != "hotfix negative" {
t.Errorf("entry = %+v, want RunID=42 Expected=failure Reason=%q", e, "hotfix negative")
}
})
}
Loading