fix: assign duty weeks on their fetch-time tab across year rollover

Around New Year the empty-rota guard keeps the old year's cached rows
while resolveRange has already moved to the new year's tab, so an
auto-assignment could write an old-tab row index onto the new tab.
Stamp each DutyWeek with the tab it was fetched from and prefer it when
building the assignment range (clock fallback for pre-upgrade caches).

Also: widen the default fetch range to A1:G200 so a full year of rows
plus roster can't be silently truncated, add -race/golangci-lint/
govulncheck gates to CI (container pinned to 1.26.4-alpine), and add
.env.example documenting every configuration variable.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-08-19 16:49:11 +02:00
parent 488f0b9a9b
commit e17aefb9e3
6 changed files with 121 additions and 27 deletions

27
.env.example Normal file
View File

@ -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

View File

@ -8,16 +8,24 @@ on:
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container: golang:1.26-alpine container: golang:1.26.4-alpine
steps: steps:
- name: Install tools - 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 - name: Checkout
run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }} run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }}
- name: Run tests - 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 - name: Log in to container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin

View File

@ -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, 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 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 environment or a `.env` file — copy `.env.example` to `.env` as a starting
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then: point; it lists every variable (including the optional `SHEET_RANGE`,
`ROTA_CACHE_PATH`, `SYNC_INTERVAL`, and the reminder settings) with defaults.
Then:
```sh ```sh
go run ./cmd/dyzur-bot 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). interrupted (Ctrl-C / SIGTERM).
When `SHEET_RANGE` is unset, the bot reads the current year's tab (e.g. 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 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 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 the last-good cache instead of clearing it; if no sync succeeds for 6h the bot

View File

@ -218,14 +218,27 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
return func(ctx context.Context) (Rota, error) { return func(ctx context.Context) (Rota, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout) ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel() 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 { if err != nil {
return Rota{}, err 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 // 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 // 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. // 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 { return func(ctx context.Context, week DutyWeek, p1, p2 string) error {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout) ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel() defer cancel()
rng := assignRange(sheetRange, time.Now(), week.Row) rng := assignRangeFor(week, sheetRange, time.Now())
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}}) return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
} }
} }
// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are // assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2
// columns B and C). It reuses the tab from resolveRange so the year rollover and // are columns B and C). An explicit sheetRange keeps its tab verbatim, matching
// any explicit range are honored identically to reads. // reads. Otherwise the week's fetch-time tab is preferred over clock resolution:
func assignRange(sheetRange string, now time.Time, row int) string { // around New Year the cache can hold the old tab's rows (the empty-rota guard
tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!") // keeps them while the next year's tab is unfilled) after resolveRange has moved
return fmt.Sprintf("%s!B%d:C%d", tab, row, row) // 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 // 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"). // 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 // 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 { func resolveRange(explicit string, now time.Time) string {
if explicit != "" { if explicit != "" {
return 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. // Service ties the Store and Syncer together for the bot to consume.

View File

@ -181,16 +181,43 @@ func TestSyncSuccessReplacesCache(t *testing.T) {
} }
} }
func TestAssignRange(t *testing.T) { // Regression test for the year-boundary tab/row divergence: a week cached from
now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC) // 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. // Week fetched from the 2026 tab, assigned after the clock rolled to 2027.
if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want { week := DutyWeek{Row: 54, Tab: "2026"}
t.Errorf("assignRange(\"\"): got %q, want %q", got, want) 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 { // A week without a recorded tab (cache written by an older version) falls
t.Errorf("assignRange(explicit): got %q, want %q", got, want) // 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) now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
// Empty range resolves to the current year's tab, evaluated against now — // Empty range resolves to the current year's tab, evaluated against now —
// this is what advances the range after a New Year rollover. // this is what advances the range after a New Year rollover. The row cap
if got, want := resolveRange("", now), "2027!A1:G100"; got != want { // (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) t.Errorf("resolveRange(\"\"): got %q, want %q", got, want)
} }
// An explicit range is honored verbatim regardless of the year. // An explicit range is honored verbatim regardless of the year.

View File

@ -32,6 +32,11 @@ type DutyWeek struct {
// cells when writing an assignment back. Valid only when the read range // cells when writing an assignment back. Valid only when the read range
// starts at A1 (as resolveRange always produces); 0 means unknown. // starts at A1 (as resolveRange always produces); 0 means unknown.
Row int 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 EG). // Person is a single entry from the people list (columns EG).