diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..310ebba --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env and fill in the required values. Never commit .env or the +# service-account key file. + +# --- Required --- +# Telegram bot token from @BotFather. +BOT_TOKEN= +# Path to the Google service-account JSON key. The sheet must be shared with +# the service account's client_email (Editor for assignment writes). +GOOGLE_CREDENTIALS_FILE= +# Spreadsheet ID from the Google Sheets URL. +GOOGLE_SHEETS_ID= + +# --- Optional (defaults shown) --- +# Range to read; leave unset to follow the current year's tab automatically +# (e.g. 2026!A1:G200), including the New Year rollover. +#SHEET_RANGE= +# Path of the local JSON rota cache. +#ROTA_CACHE_PATH=rota.json +# Background sync frequency (Go duration). +#SYNC_INTERVAL=30m + +# --- Reminders (disabled unless GROUP_CHAT_ID is set) --- +# Telegram chat ID to post weekly reminders to. +#GROUP_CHAT_ID= +# Day and hour (0-23, Europe/Warsaw time) the weekly reminder fires. +#REMINDER_WEEKDAY=Monday +#REMINDER_HOUR=9 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c6dca71..c752515 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -8,16 +8,24 @@ on: jobs: build: runs-on: docker - container: golang:1.26-alpine + container: golang:1.26.4-alpine steps: - name: Install tools - run: apk add --no-cache git docker-cli + # build-base provides the C toolchain the race detector needs (-race + # implies CGO). + run: apk add --no-cache git docker-cli build-base - name: Checkout run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }} - name: Run tests - run: go test ./... + run: go test -race ./... + + - name: Run linter + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run + + - name: Check for known vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... - name: Log in to container registry run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin diff --git a/README.md b/README.md index 07dc7bf..613e9e6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ Backend read the schedule from a Google sheet, store it in local JSON file. Requires `GOOGLE_CREDENTIALS_FILE` (path to a Google service-account key, shared as an editor on the sheet), `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the -environment or a `.env` file (see `service.go`/`sheet.go` for optional -`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then: +environment or a `.env` file — copy `.env.example` to `.env` as a starting +point; it lists every variable (including the optional `SHEET_RANGE`, +`ROTA_CACHE_PATH`, `SYNC_INTERVAL`, and the reminder settings) with defaults. +Then: ```sh go run ./cmd/dyzur-bot @@ -20,7 +22,7 @@ The service loads the cached rota, syncs from Google Sheets in the background on interrupted (Ctrl-C / SIGTERM). When `SHEET_RANGE` is unset, the bot reads the current year's tab (e.g. -`2026!A1:G100`), resolved on every sync — so it follows the New Year rollover +`2026!A1:G200`), resolved on every sync — so it follows the New Year rollover automatically without a restart. A sync that fails, or that returns no duty weeks (e.g. a next-year tab that exists but hasn't been filled in yet), keeps the last-good cache instead of clearing it; if no sync succeeds for 6h the bot diff --git a/service.go b/service.go index 3df4b5c..f1a2b9c 100644 --- a/service.go +++ b/service.go @@ -218,14 +218,27 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc { return func(ctx context.Context) (Rota, error) { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() - rows, err := ReadSheet(ctx, spreadsheetID, resolveRange(readRange, time.Now())) + rng := resolveRange(readRange, time.Now()) + rows, err := ReadSheet(ctx, spreadsheetID, rng) if err != nil { return Rota{}, err } - return ParseRota(rows), nil + return stampTabs(ParseRota(rows), rng), nil } } +// stampTabs records on each week the tab its rows were fetched from, so an +// assignment written later (possibly after the clock-resolved tab has rolled to +// the next year while the cache still holds this fetch) targets the tab the +// week's Row is valid on. +func stampTabs(rota Rota, resolvedRange string) Rota { + tab, _, _ := strings.Cut(resolvedRange, "!") + for i := range rota.Weeks { + rota.Weeks[i].Tab = tab + } + return rota +} + // SheetAssigner returns an AssignFunc that writes a week's two names into its // B:C cells on the same tab the rota is read from. sheetRange mirrors the // fetcher's range so the tab (year or explicit) matches. @@ -233,28 +246,38 @@ func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc { return func(ctx context.Context, week DutyWeek, p1, p2 string) error { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() - rng := assignRange(sheetRange, time.Now(), week.Row) + rng := assignRangeFor(week, sheetRange, time.Now()) return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}}) } } -// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are -// columns B and C). It reuses the tab from resolveRange so the year rollover and -// any explicit range are honored identically to reads. -func assignRange(sheetRange string, now time.Time, row int) string { - tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!") - return fmt.Sprintf("%s!B%d:C%d", tab, row, row) +// assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2 +// are columns B and C). An explicit sheetRange keeps its tab verbatim, matching +// reads. Otherwise the week's fetch-time tab is preferred over clock resolution: +// around New Year the cache can hold the old tab's rows (the empty-rota guard +// keeps them while the next year's tab is unfilled) after resolveRange has moved +// to the new tab, and writing an old-tab Row onto the new tab would corrupt it. +// A week without a recorded tab (cache from an older version) falls back to the +// clock, preserving prior behavior. +func assignRangeFor(week DutyWeek, sheetRange string, now time.Time) string { + tab := week.Tab + if sheetRange != "" || tab == "" { + tab, _, _ = strings.Cut(resolveRange(sheetRange, now), "!") + } + return fmt.Sprintf("%s!B%d:C%d", tab, week.Row, week.Row) } // resolveRange returns the explicit range when set, otherwise the default that // targets the current year's tab (sheets are named per year, e.g. "2026"). // Resolving against now (not a startup-captured value) is what lets the range -// advance to the next year's tab automatically. +// advance to the next year's tab automatically. The 200-row cap bounds the +// fetch; rows past it are silently dropped, so it must stay comfortably above +// one year of weekly rows (~53) plus roster entries. func resolveRange(explicit string, now time.Time) string { if explicit != "" { return explicit } - return fmt.Sprintf("%d!A1:G100", now.Year()) + return fmt.Sprintf("%d!A1:G200", now.Year()) } // Service ties the Store and Syncer together for the bot to consume. diff --git a/service_test.go b/service_test.go index 1462c00..cb3a9de 100644 --- a/service_test.go +++ b/service_test.go @@ -181,16 +181,43 @@ func TestSyncSuccessReplacesCache(t *testing.T) { } } -func TestAssignRange(t *testing.T) { - now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC) +// Regression test for the year-boundary tab/row divergence: a week cached from +// the old year's tab (e.g. kept by the empty-rota guard across New Year) must be +// assigned on the tab it was fetched from, not the tab the wall clock resolves +// to — its Row is only meaningful on the fetch tab. +func TestAssignRangeForUsesFetchTab(t *testing.T) { + jan2027 := time.Date(2027, 1, 4, 9, 0, 0, 0, time.UTC) - // Empty range -> current-year tab; the assignment targets cols B:C of the row. - if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want { - t.Errorf("assignRange(\"\"): got %q, want %q", got, want) + // Week fetched from the 2026 tab, assigned after the clock rolled to 2027. + week := DutyWeek{Row: 54, Tab: "2026"} + if got, want := assignRangeFor(week, "", jan2027), "2026!B54:C54"; got != want { + t.Errorf("assignRangeFor(fetch tab): got %q, want %q", got, want) } - // Explicit range -> its tab is reused verbatim. - if got, want := assignRange("Dyżury!A1:C30", now, 7), "Dyżury!B7:C7"; got != want { - t.Errorf("assignRange(explicit): got %q, want %q", got, want) + + // A week without a recorded tab (cache written by an older version) falls + // back to clock-resolved tab, matching the previous behavior. + legacy := DutyWeek{Row: 54} + if got, want := assignRangeFor(legacy, "", jan2027), "2027!B54:C54"; got != want { + t.Errorf("assignRangeFor(legacy fallback): got %q, want %q", got, want) + } + + // An explicit range keeps its tab verbatim over the week's recorded tab. + explicit := DutyWeek{Row: 7, Tab: "2026"} + if got, want := assignRangeFor(explicit, "Dyżury!A1:C30", jan2027), "Dyżury!B7:C7"; got != want { + t.Errorf("assignRangeFor(explicit): got %q, want %q", got, want) + } +} + +func TestStampTabs(t *testing.T) { + rota := ParseRota(sampleRows()) + stamped := stampTabs(rota, "2026!A1:G200") + if len(stamped.Weeks) == 0 { + t.Fatal("sample rota has no weeks") + } + for _, w := range stamped.Weeks { + if w.Tab != "2026" { + t.Errorf("week %s: Tab = %q, want %q", w.WeekStart.Format("2006-01-02"), w.Tab, "2026") + } } } @@ -198,8 +225,10 @@ func TestResolveRange(t *testing.T) { now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) // Empty range resolves to the current year's tab, evaluated against now — - // this is what advances the range after a New Year rollover. - if got, want := resolveRange("", now), "2027!A1:G100"; got != want { + // this is what advances the range after a New Year rollover. The row cap + // (200) is a deliberate constant: rows past it are silently dropped by the + // fetch, so keep it comfortably above one year of weekly rows. + if got, want := resolveRange("", now), "2027!A1:G200"; got != want { t.Errorf("resolveRange(\"\"): got %q, want %q", got, want) } // An explicit range is honored verbatim regardless of the year. diff --git a/sheet.go b/sheet.go index 1b2d066..d24e3fa 100644 --- a/sheet.go +++ b/sheet.go @@ -32,6 +32,11 @@ type DutyWeek struct { // cells when writing an assignment back. Valid only when the read range // starts at A1 (as resolveRange always produces); 0 means unknown. Row int + // Tab is the sheet tab Row was fetched from (e.g. "2026"). Row is only + // meaningful on this tab: around New Year the cache may still hold the old + // year's rows while the clock-resolved tab has moved on, so an assignment + // must target Tab, not the current year. "" means unknown (older cache). + Tab string } // Person is a single entry from the people list (columns E–G).