feat(go): add --dry-run to fuj sync
All checks were successful
Deploy to K8s / deploy (push) Successful in 18s
All checks were successful
Deploy to K8s / deploy (push) Successful in 18s
Mirror fuj infer's read-only mode: SyncOpts.DryRun skips WriteHeader, AppendValues, and SortByDateColumn, printing one "Dry run: would …" line per planned operation instead. ID-dedup still runs so the output reflects exactly what the next real sync would write. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
|
||||||
|
|
||||||
|
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.
|
||||||
|
- `fuj sync --dry-run` flag wired in `cmd/fuj/main.go`; mirrors existing `fuj infer --dry-run` behaviour.
|
||||||
|
- `TestSyncToSheets_DryRun` added to banksync test suite.
|
||||||
|
|
||||||
## 2026-05-07 01:03 CEST — feat(go/M4): IO layer behind interfaces
|
## 2026-05-07 01:03 CEST — feat(go/M4): IO layer behind interfaces
|
||||||
|
|
||||||
- `go/internal/io/attendance`: CSV-over-public-URL client + `Fake` for both adult and junior tabs.
|
- `go/internal/io/attendance`: CSV-over-public-URL client + `Fake` for both adult and junior tabs.
|
||||||
@@ -36,6 +42,7 @@
|
|||||||
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
|
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
|
||||||
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
|
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
|
||||||
- Two regression tests added to `tests/test_match_members.py`.
|
- Two regression tests added to `tests/test_match_members.py`.
|
||||||
|
|
||||||
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
|
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
|
||||||
|
|
||||||
- New `go/internal/domain/reconcile` package porting the three-phase payment allocation from `scripts/match_payments.py reconcile()`.
|
- New `go/internal/domain/reconcile` package porting the three-phase payment allocation from `scripts/match_payments.py reconcile()`.
|
||||||
|
|||||||
36
docs/plans/2026-05-07-1033-fuj-sync-dry-run.md
Normal file
36
docs/plans/2026-05-07-1033-fuj-sync-dry-run.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Plan: add `--dry-run` to `fuj sync`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`fuj infer` already supports `--dry-run` (it builds the planned `BatchUpdateValues`
|
||||||
|
operations, prints them, and skips the actual write — see
|
||||||
|
`go/internal/services/banksync/infer.go:136-156` and the
|
||||||
|
`Dry run: would update N row(s).` line in `go/cmd/fuj/main.go:209-213`).
|
||||||
|
|
||||||
|
`fuj sync` had no equivalent. It always committed three potential writes to the
|
||||||
|
payments sheet: `WriteHeader` (if the header row is missing/wrong), `AppendValues`
|
||||||
|
(for each new Fio transaction), and `SortByDateColumn` (if `--sort`, default true).
|
||||||
|
For inspecting what a sync *would* do — useful when debugging dedupe, sanity-checking
|
||||||
|
a date window, or wiring up the command for the first time on a new account — the
|
||||||
|
only options were pointing at a throwaway spreadsheet or reading the diff after the fact.
|
||||||
|
|
||||||
|
This change mirrors `infer`'s read-only mode for `sync`: same flag name, same output
|
||||||
|
style, same "build the data structures, print instead of writing" shape.
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
|
||||||
|
1. `go/internal/services/banksync/sync.go` — `DryRun bool` field added to `SyncOpts`; three write points gated on `opts.DryRun`
|
||||||
|
2. `go/cmd/fuj/main.go` — `--dry-run` flag added to `syncCmd`; final println split on `*dryRun`
|
||||||
|
3. `go/internal/services/banksync/sync_test.go` — `TestSyncToSheets_DryRun` added
|
||||||
|
4. `CHANGELOG.md` — entry added
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
When `--dry-run` is set:
|
||||||
|
|
||||||
|
- If the sheet header is missing/wrong → prints `Dry run: would write header row`; skips `WriteHeader`
|
||||||
|
- For each non-deduped Fio transaction → prints `Dry run: would append date=… amount=… sender=… vs=… message=…`; skips `AppendValues`
|
||||||
|
- If `--sort` is true → prints `Dry run: would sort by date`; skips `SortByDateColumn`
|
||||||
|
- Returns `len(newRows)` so the caller can print `Dry run: would sync N new transaction(s).`
|
||||||
|
|
||||||
|
The existing ID-dedup logic runs in full even during dry-run (reads the sheet, builds `existingIDs`), so the output reflects exactly what the next real sync would do.
|
||||||
@@ -134,8 +134,9 @@ func syncCmd(args []string) {
|
|||||||
fromStr := fs.String("from", "", "start date YYYY-MM-DD")
|
fromStr := fs.String("from", "", "start date YYYY-MM-DD")
|
||||||
toStr := fs.String("to", "", "end date YYYY-MM-DD")
|
toStr := fs.String("to", "", "end date YYYY-MM-DD")
|
||||||
sort := fs.Bool("sort", true, "sort sheet by date after appending")
|
sort := fs.Bool("sort", true, "sort sheet by date after appending")
|
||||||
|
dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort]")
|
fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run]")
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
}
|
}
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -153,7 +154,7 @@ func syncCmd(args []string) {
|
|||||||
}
|
}
|
||||||
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
|
||||||
|
|
||||||
opts := banksync.SyncOpts{Days: *days, Sort: *sort}
|
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun}
|
||||||
if *fromStr != "" && *toStr != "" {
|
if *fromStr != "" && *toStr != "" {
|
||||||
opts.From, err = time.Parse("2006-01-02", *fromStr)
|
opts.From, err = time.Parse("2006-01-02", *fromStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,7 +173,11 @@ func syncCmd(args []string) {
|
|||||||
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
fmt.Fprintf(os.Stderr, "fuj sync: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if *dryRun {
|
||||||
|
fmt.Printf("Dry run: would sync %d new transaction(s).\n", n)
|
||||||
|
} else {
|
||||||
fmt.Printf("Synced %d new transaction(s).\n", n)
|
fmt.Printf("Synced %d new transaction(s).\n", n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func inferCmd(args []string) {
|
func inferCmd(args []string) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type SyncOpts struct {
|
|||||||
Days int // look-back window when From/To are zero
|
Days int // look-back window when From/To are zero
|
||||||
From, To time.Time // explicit window (overrides Days)
|
From, To time.Time // explicit window (overrides Days)
|
||||||
Sort bool // sort the sheet by Date after appending
|
Sort bool // sort the sheet by Date after appending
|
||||||
|
DryRun bool // print planned writes without modifying the sheet
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
|
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
|
||||||
@@ -52,9 +53,13 @@ func SyncToSheets(
|
|||||||
if len(rows) > 0 {
|
if len(rows) > 0 {
|
||||||
header := rows[0]
|
header := rows[0]
|
||||||
if !headerMatches(header) {
|
if !headerMatches(header) {
|
||||||
|
if opts.DryRun {
|
||||||
|
fmt.Println("Dry run: would write header row")
|
||||||
|
} else {
|
||||||
if err := sh.WriteHeader(ctx, spreadsheetID, columnLabels); err != nil {
|
if err := sh.WriteHeader(ctx, spreadsheetID, columnLabels); err != nil {
|
||||||
return 0, fmt.Errorf("sync: write header: %w", err)
|
return 0, fmt.Errorf("sync: write header: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, row := range rows[1:] {
|
for _, row := range rows[1:] {
|
||||||
if len(row) > 10 {
|
if len(row) > 10 {
|
||||||
@@ -113,6 +118,17 @@ func SyncToSheets(
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.DryRun {
|
||||||
|
for _, row := range newRows {
|
||||||
|
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
|
||||||
|
row[0], row[1], row[6], row[7], row[8])
|
||||||
|
}
|
||||||
|
if opts.Sort {
|
||||||
|
fmt.Println("Dry run: would sort by date")
|
||||||
|
}
|
||||||
|
return len(newRows), nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := sh.AppendValues(ctx, spreadsheetID, "A2", newRows); err != nil {
|
if err := sh.AppendValues(ctx, spreadsheetID, "A2", newRows); err != nil {
|
||||||
return 0, fmt.Errorf("sync: append: %w", err)
|
return 0, fmt.Errorf("sync: append: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,23 @@ func TestSyncToSheets_ExplicitDateWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncToSheets_DryRun(t *testing.T) {
|
||||||
|
sh := &sheets.Fake{Values: map[string][][]any{"SHEETID/A1:K": {}}}
|
||||||
|
fioFake := &fio.Fake{Transactions: testFioTxns}
|
||||||
|
|
||||||
|
n, err := SyncToSheets(context.Background(), "SHEETID", fioFake, sh,
|
||||||
|
SyncOpts{Days: 30, Sort: true, DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Errorf("want 2 planned, got %d", n)
|
||||||
|
}
|
||||||
|
if len(sh.Appended) != 0 {
|
||||||
|
t.Error("dry-run must not call AppendValues")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
|
// syncIDFor mirrors what SyncToSheets computes for a given fio.Transaction.
|
||||||
func syncIDFor(tx fio.Transaction) string {
|
func syncIDFor(tx fio.Transaction) string {
|
||||||
currency := tx.Currency
|
currency := tx.Currency
|
||||||
|
|||||||
Reference in New Issue
Block a user