From f787fa32b155ed1cab6f704d4733ce6b6175c18d Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Thu, 14 May 2026 00:02:47 +0100 Subject: [PATCH 1/3] Refactor & cleanup code --- AGENTS.md | 2 - compose.yaml | 7 +- config.yaml | 8 + internal/availability/availability.go | 115 ++++++---- internal/availability/availability_test.go | 2 +- internal/availability/handler.go | 2 +- internal/availability/holiday.go | 22 +- internal/availability/syncer.go | 27 +-- internal/calendar/client.go | 1 - internal/calendar/handler.go | 26 +-- internal/calendar/ical.go | 42 +--- internal/calendar/ical_test.go | 40 ++-- internal/config/config.go | 22 +- internal/config/config_test.go | 67 +++++- internal/feed/feed.go | 34 +++ internal/github/target.go | 64 +++--- internal/github/target_test.go | 115 +++++----- internal/poll/poll.go | 27 +++ internal/store/keys.go | 11 +- internal/store/store.go | 240 ++++++--------------- internal/store/store_test.go | 122 +++++------ main.go | 91 ++++---- 22 files changed, 528 insertions(+), 559 deletions(-) create mode 100644 internal/feed/feed.go create mode 100644 internal/poll/poll.go diff --git a/AGENTS.md b/AGENTS.md index 098aaa0..2c6e691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,6 @@ This repository is a Go service that syncs calendar status and exposes availabil - Pebble key design includes: - `status` for the current status record - `event:{eventID}` for stored calendar events - - `channel:{channelID}` for push notification channels - - `sync:{calendarID}` for incremental sync tokens - `availability` for the latest raw availability snapshot - `availability_dirty` for tracking if availability changed since last deploy (stores pending JSON) - `availability_last_deployed` for tracking the availability entries JSON from the last successful deploy diff --git a/compose.yaml b/compose.yaml index c9834c3..7afb055 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,6 +11,9 @@ # AVAILABILITY_WORKING_HOURS_START # AVAILABILITY_WORKING_HOURS_END # AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS +# BUILD_IS_ENABLED +# BUILD_INTERVAL +# BUILD_CF_DEPLOY_HOOK # # Usage: # podman compose up -d @@ -29,10 +32,12 @@ services: PEBBLE_PATH: ${PEBBLE_PATH:-/data} CALENDAR_URL: ${CALENDAR_URL} GITHUB_TOKEN: ${GITHUB_TOKEN} - GITHUB_USERNAME: ${GITHUB_USERNAME} AVAILABILITY_IS_ENABLED: ${AVAILABILITY_IS_ENABLED} AVAILABILITY_CALENDAR_URL: ${AVAILABILITY_CALENDAR_URL} AVAILABILITY_API_KEY: ${AVAILABILITY_API_KEY} AVAILABILITY_WORKING_HOURS_START: ${AVAILABILITY_WORKING_HOURS_START} AVAILABILITY_WORKING_HOURS_END: ${AVAILABILITY_WORKING_HOURS_END} AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS: ${AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS} + BUILD_IS_ENABLED: ${BUILD_IS_ENABLED} + BUILD_INTERVAL: ${BUILD_INTERVAL} + BUILD_CF_DEPLOY_HOOK: ${BUILD_CF_DEPLOY_HOOK} diff --git a/config.yaml b/config.yaml index 0229af1..1d0b85e 100644 --- a/config.yaml +++ b/config.yaml @@ -44,3 +44,11 @@ availability: - name: Evening start: "17:30" end: "22:00" + +build: + # Disabled by default. Set to true to trigger Cloudflare Pages deploys when availability changes. + is_enabled: false + # Deploy checks are aligned to HH:01, HH:11, HH:21, ... when interval is 10m. + interval: 10m + # Cloudflare Pages build hook URL. Prefer BUILD_CF_DEPLOY_HOOK for secrets. + cf_deploy_hook: "" diff --git a/internal/availability/availability.go b/internal/availability/availability.go index aa99164..b795a27 100644 --- a/internal/availability/availability.go +++ b/internal/availability/availability.go @@ -1,7 +1,6 @@ package availability import ( - "context" "encoding/json" "errors" "fmt" @@ -97,7 +96,7 @@ func NewProvider(st *store.Store, blocks []Block, workingHours WorkingHours, exc } // GetEntries returns current availability entries. -func (p *Provider) GetEntries(ctx context.Context) ([]Entry, error) { +func (p *Provider) GetEntries() ([]Entry, error) { snap, ok, err := p.store.GetAvailabilitySnapshot() if err != nil { return nil, fmt.Errorf("get availability snapshot: %w", err) @@ -126,8 +125,8 @@ func (p *Provider) GetEntries(ctx context.Context) ([]Entry, error) { } // GetEntriesJSON returns current availability entries serialized as JSON. -func (p *Provider) GetEntriesJSON(ctx context.Context) ([]byte, error) { - entries, err := p.GetEntries(ctx) +func (p *Provider) GetEntriesJSON() ([]byte, error) { + entries, err := p.GetEntries() if err != nil { return nil, err } @@ -149,62 +148,100 @@ func Compute(body string, timezone string, blocks []Block, opts ComputeOptions) loc := loadLocation(timezone) nowLocal := opts.Now.In(loc) dayStart := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) - windowStart := dayStart windowEnd := dayStart.AddDate(0, 0, 10) - parsed, err := calendar.ParseICalendar([]byte(body), windowStart, windowEnd) + parsed, err := calendar.ParseICalendar([]byte(body), dayStart, windowEnd) if err != nil { return nil, err } - holidaySet := make(map[string]struct{}, len(opts.HolidayDates)) - if opts.ExcludeEnglandBankHolidays { - for _, date := range opts.HolidayDates { - if date == "" { - continue - } - holidaySet[date] = struct{}{} - } - } + holidaySet := holidayDatesSet(opts.HolidayDates, opts.ExcludeEnglandBankHolidays) entries := make([]Entry, 0, 10) for dayOffset := 0; dayOffset < 10; dayOffset++ { day := dayStart.AddDate(0, 0, dayOffset) - dayKey := day.Format("2006-01-02") - _, isHoliday := holidaySet[dayKey] - applyWorkingHours := day.Weekday() != time.Saturday && day.Weekday() != time.Sunday - if opts.ExcludeEnglandBankHolidays && isHoliday { - applyWorkingHours = false - } - workingStart := day.Add(opts.WorkingHours.Start) - workingEnd := day.Add(opts.WorkingHours.End) - - for _, block := range blocks { - blockStart := day.Add(block.Start) - blockEnd := day.Add(block.End) - - if dayOffset == 0 && blockStart.Before(nowLocal) { - continue - } - if applyWorkingHours && overlaps(blockStart, blockEnd, workingStart, workingEnd) { - continue - } - if !blockIsFree(parsed.Events, blockStart, blockEnd, loc) { - continue - } - + block, ok := firstFreeBlock( + parsed.Events, + blocks, + day, + dayOffset == 0, + nowLocal, + opts.WorkingHours, + holidaySet, + opts.ExcludeEnglandBankHolidays, + loc, + ) + if ok { entries = append(entries, Entry{ DayOfWeek: day.Weekday().String(), Block: block.Name, Date: day.Format("2006-01-02"), }) - break } } return entries, nil } +func holidayDatesSet(dates []string, enabled bool) map[string]struct{} { + holidaySet := make(map[string]struct{}, len(dates)) + if !enabled { + return holidaySet + } + for _, date := range dates { + if date == "" { + continue + } + holidaySet[date] = struct{}{} + } + return holidaySet +} + +func firstFreeBlock( + events []calendar.ParsedEvent, + blocks []Block, + day time.Time, + isToday bool, + now time.Time, + workingHours WorkingHours, + holidaySet map[string]struct{}, + excludeHolidays bool, + loc *time.Location, +) (Block, bool) { + applyWorkingHours := isWeekday(day) && !isExcludedHoliday(day, holidaySet, excludeHolidays) + workingStart := day.Add(workingHours.Start) + workingEnd := day.Add(workingHours.End) + + for _, block := range blocks { + blockStart := day.Add(block.Start) + blockEnd := day.Add(block.End) + + if isToday && blockStart.Before(now) { + continue + } + if applyWorkingHours && overlaps(blockStart, blockEnd, workingStart, workingEnd) { + continue + } + if !blockIsFree(events, blockStart, blockEnd, loc) { + continue + } + return block, true + } + return Block{}, false +} + +func isWeekday(day time.Time) bool { + return day.Weekday() != time.Saturday && day.Weekday() != time.Sunday +} + +func isExcludedHoliday(day time.Time, holidaySet map[string]struct{}, enabled bool) bool { + if !enabled { + return false + } + _, ok := holidaySet[day.Format("2006-01-02")] + return ok +} + func parseClockRange(startValue, endValue string) (time.Duration, time.Duration, error) { start, err := parseClock(strings.TrimSpace(startValue)) if err != nil { diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go index 571af50..86f30fa 100644 --- a/internal/availability/availability_test.go +++ b/internal/availability/availability_test.go @@ -317,7 +317,7 @@ func TestProvider_GetEntriesJSON(t *testing.T) { p := NewProvider(st, blocks, testWorkingHours(t), false) - data, err := p.GetEntriesJSON(context.Background()) + data, err := p.GetEntriesJSON() if err != nil { t.Fatalf("GetEntriesJSON: %v", err) } diff --git a/internal/availability/handler.go b/internal/availability/handler.go index 725a5c3..9fd08a0 100644 --- a/internal/availability/handler.go +++ b/internal/availability/handler.go @@ -31,7 +31,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - entries, err := h.provider.GetEntries(r.Context()) + entries, err := h.provider.GetEntries() if err != nil { if errors.Is(err, ErrSnapshotNotFound) { http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) diff --git a/internal/availability/holiday.go b/internal/availability/holiday.go index eeee46a..521e6d2 100644 --- a/internal/availability/holiday.go +++ b/internal/availability/holiday.go @@ -4,12 +4,11 @@ import ( "context" "encoding/json" "fmt" - "io" - "net/http" "time" "github.com/rs/zerolog" + "github.com/gldraphael/status/internal/feed" "github.com/gldraphael/status/internal/store" ) @@ -27,27 +26,10 @@ func NewHolidayClient() *HolidayClient { // Fetch returns the raw bank-holidays JSON body. func (c *HolidayClient) Fetch(ctx context.Context) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + body, err := feed.FetchBody(ctx, c.url, 30*time.Second) if err != nil { return nil, fmt.Errorf("fetch bank holidays: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetch bank holidays: unexpected status %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - return body, nil } diff --git a/internal/availability/syncer.go b/internal/availability/syncer.go index 1c2c2ea..2dd120e 100644 --- a/internal/availability/syncer.go +++ b/internal/availability/syncer.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/gldraphael/status/internal/calendar" + "github.com/gldraphael/status/internal/poll" "github.com/gldraphael/status/internal/store" ) @@ -36,23 +37,15 @@ func NewSyncer(st *store.Store, provider *Provider, cal feedClient, logger zerol // Run starts the sync loop and blocks until ctx is cancelled. func (s *Syncer) Run(ctx context.Context, interval time.Duration) error { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - if err := s.syncOnce(ctx); err != nil { - s.logger.Error().Err(err).Msg("sync availability on startup") - } - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - if err := s.syncOnce(ctx); err != nil { - s.logger.Error().Err(err).Msg("sync availability cycle") - } + return poll.Every(ctx, interval, func() error { + return s.syncOnce(ctx) + }, func(err error, startup bool) { + msg := "sync availability cycle" + if startup { + msg = "sync availability on startup" } - } + s.logger.Error().Err(err).Msg(msg) + }) } func (s *Syncer) syncOnce(ctx context.Context) error { @@ -76,7 +69,7 @@ func (s *Syncer) syncOnce(ctx context.Context) error { } // Change detection: compare current entries with last deployed ones. - currentEntries, err := s.provider.GetEntriesJSON(ctx) + currentEntries, err := s.provider.GetEntriesJSON() if err != nil { return fmt.Errorf("compute current availability: %w", err) } diff --git a/internal/calendar/client.go b/internal/calendar/client.go index 9e2f427..5401992 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -29,7 +29,6 @@ type ChangedEvent struct { } // FetchEvents fetches all events from the iCal URL. -// The syncToken parameter is ignored (kept for compatibility). func (c *Client) FetchEvents(ctx context.Context) ([]ChangedEvent, error) { parsed, err := FetchAndParseICalendar(ctx, c.calendarURL) if err != nil { diff --git a/internal/calendar/handler.go b/internal/calendar/handler.go index 612300d..65ffe94 100644 --- a/internal/calendar/handler.go +++ b/internal/calendar/handler.go @@ -8,6 +8,7 @@ import ( "github.com/rs/zerolog" + "github.com/gldraphael/status/internal/poll" "github.com/gldraphael/status/internal/store" "github.com/gldraphael/status/internal/target" ) @@ -38,24 +39,15 @@ func NewSyncer(st *store.Store, cal calendarClient, targets []target.Target, log // Run starts the sync loop, fetching events and syncing status at the given interval. // Run blocks until ctx is cancelled. func (s *Syncer) Run(ctx context.Context, interval time.Duration) error { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - // Sync immediately on startup. - if err := s.syncOnce(ctx); err != nil { - s.logger.Error().Err(err).Msg("sync on startup") - } - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - if err := s.syncOnce(ctx); err != nil { - s.logger.Error().Err(err).Msg("sync cycle") - } + return poll.Every(ctx, interval, func() error { + return s.syncOnce(ctx) + }, func(err error, startup bool) { + msg := "sync cycle" + if startup { + msg = "sync on startup" } - } + s.logger.Error().Err(err).Msg(msg) + }) } func (s *Syncer) syncOnce(ctx context.Context) error { diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index a19ce84..0e7057b 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -3,13 +3,13 @@ package calendar import ( "context" "fmt" - "io" - "net/http" "strings" "time" ics "github.com/arran4/golang-ical" "github.com/teambition/rrule-go" + + "github.com/gldraphael/status/internal/feed" ) // ParsedEvent is an event extracted from an iCal file. @@ -30,27 +30,10 @@ type ParsedCalendar struct { // FetchICalendarBody fetches the raw iCal body from the given URL. func FetchICalendarBody(ctx context.Context, calendarURL string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, calendarURL, nil) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + body, err := feed.FetchBody(ctx, calendarURL, 30*time.Second) if err != nil { return nil, fmt.Errorf("fetch calendar: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("fetch calendar: unexpected status %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - return body, nil } @@ -214,25 +197,6 @@ func ExtractICalendarTimezone(data []byte) (string, error) { return calendarTimezone(cal), nil } -// parseICalendar is a compatibility wrapper used by the existing tests. -func parseICalendar(data interface{}, now time.Time) ([]ParsedEvent, error) { - var body []byte - switch v := data.(type) { - case []byte: - body = v - case string: - body = []byte(v) - default: - return nil, fmt.Errorf("unsupported data type") - } - - parsed, err := ParseICalendar(body, now.Add(-24*time.Hour), now.Add(24*time.Hour)) - if err != nil { - return nil, err - } - return parsed.Events, nil -} - func calendarTimezone(cal *ics.Calendar) string { for _, prop := range cal.CalendarProperties { switch prop.IANAToken { diff --git a/internal/calendar/ical_test.go b/internal/calendar/ical_test.go index 126205e..43e7c4c 100644 --- a/internal/calendar/ical_test.go +++ b/internal/calendar/ical_test.go @@ -9,6 +9,14 @@ import ( "time" ) +func parseEvents(data string, now time.Time) ([]ParsedEvent, error) { + parsed, err := ParseICalendar([]byte(data), now.Add(-24*time.Hour), now.Add(24*time.Hour)) + if err != nil { + return nil, err + } + return parsed.Events, nil +} + func TestParseICalendar_BasicEvent(t *testing.T) { icalData := `BEGIN:VCALENDAR PRODID:-//Test//Test Calendar//EN @@ -22,9 +30,9 @@ END:VEVENT END:VCALENDAR` now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 1 { @@ -58,9 +66,9 @@ END:VEVENT END:VCALENDAR` now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 2 { @@ -88,9 +96,9 @@ END:VEVENT END:VCALENDAR` now := time.Date(2026, 4, 6, 10, 30, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 1 { @@ -114,9 +122,9 @@ END:VEVENT END:VCALENDAR` now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 1 { @@ -259,9 +267,9 @@ END:VCALENDAR` // Test a date well after the initial DTSTART to verify RRULE expansion. now := time.Date(2026, 4, 15, 9, 30, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } found := false @@ -304,9 +312,9 @@ END:VCALENDAR` // now - 24h is 2026-04-06 12:00. // The event started at 2026-04-06 09:00, which is BEFORE the window. - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } found := false @@ -339,9 +347,9 @@ END:VCALENDAR` // 2026-04-07 is Tuesday, in the middle of the event. now := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 1 { @@ -405,9 +413,9 @@ END:VEVENT END:VCALENDAR` now := time.Date(2026, 4, 6, 10, 0, 0, 0, time.UTC) - events, err := parseICalendar([]byte(icalData), now) + events, err := parseEvents(icalData, now) if err != nil { - t.Fatalf("parseICalendar: %v", err) + t.Fatalf("parseEvents: %v", err) } if len(events) != 3 { diff --git a/internal/config/config.go b/internal/config/config.go index a7b7f02..3e02f31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,8 +58,6 @@ type AvailabilityBlockConfig struct { End string `koanf:"end"` // HH:MM, 24-hour clock } -// envMapping maps environment variable names to koanf config keys. -// Only variables listed here are loaded; all others are ignored. // BuildConfig configures automatic builds/deploys. type BuildConfig struct { IsEnabled bool `koanf:"is_enabled"` @@ -74,7 +72,6 @@ var envMapping = map[string]string{ "PEBBLE_PATH": "pebble_path", "CALENDAR_URL": "calendar_url", "GITHUB_TOKEN": "targets.github.token", - "GITHUB_USERNAME": "targets.github.username", "AVAILABILITY_IS_ENABLED": "availability.is_enabled", "AVAILABILITY_CALENDAR_URL": "availability.calendar_url", "AVAILABILITY_API_KEY": "availability.api_key", @@ -196,18 +193,27 @@ func (b BuildConfig) Validate() error { if !b.IsEnabled { return nil } + _, err := b.IntervalDuration() + return err +} + +// IntervalDuration returns the validated build interval. +func (b BuildConfig) IntervalDuration() (time.Duration, error) { + if !b.IsEnabled { + return 0, nil + } if strings.TrimSpace(b.Interval) == "" { - return fmt.Errorf("build.interval is required when build is enabled") + return 0, fmt.Errorf("build.interval is required when build is enabled") } dur, err := time.ParseDuration(b.Interval) if err != nil { - return fmt.Errorf("build.interval: %w", err) + return 0, fmt.Errorf("build.interval: %w", err) } if dur < time.Minute { - return fmt.Errorf("build.interval must be at least 1m") + return 0, fmt.Errorf("build.interval must be at least 1m") } if strings.TrimSpace(b.CfDeployHook) == "" { - return fmt.Errorf("build.cf_deploy_hook is required when build is enabled") + return 0, fmt.Errorf("build.cf_deploy_hook is required when build is enabled") } - return nil + return dur, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 68618ba..dc336dd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) // chdir changes the working directory for the duration of the test. @@ -25,7 +26,7 @@ func TestLoad_Defaults(t *testing.T) { // Work in a temp dir with no config.yaml. chdir(t, t.TempDir()) - for _, key := range []string{"PORT", "PEBBLE_PATH", "CALENDAR_URL", "GITHUB_TOKEN", "GITHUB_USERNAME", "AVAILABILITY_IS_ENABLED", "AVAILABILITY_CALENDAR_URL", "AVAILABILITY_API_KEY", "AVAILABILITY_WORKING_HOURS_START", "AVAILABILITY_WORKING_HOURS_END", "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "BUILD_IS_ENABLED", "BUILD_INTERVAL", "BUILD_CF_DEPLOY_HOOK"} { + for _, key := range []string{"PORT", "PEBBLE_PATH", "CALENDAR_URL", "GITHUB_TOKEN", "AVAILABILITY_IS_ENABLED", "AVAILABILITY_CALENDAR_URL", "AVAILABILITY_API_KEY", "AVAILABILITY_WORKING_HOURS_START", "AVAILABILITY_WORKING_HOURS_END", "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "BUILD_IS_ENABLED", "BUILD_INTERVAL", "BUILD_CF_DEPLOY_HOOK"} { t.Setenv(key, "") } @@ -60,7 +61,6 @@ func TestLoad_FromEnv(t *testing.T) { t.Setenv("PEBBLE_PATH", "/tmp/mydb") t.Setenv("CALENDAR_URL", "https://calendar.example.com/ical.ics") t.Setenv("GITHUB_TOKEN", "gh-abc123") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -93,7 +93,6 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "true") t.Setenv("AVAILABILITY_CALENDAR_URL", "https://availability.example.com/ical.ics") t.Setenv("AVAILABILITY_API_KEY", "secret-key") @@ -127,7 +126,6 @@ func TestLoad_InvalidPort(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("PORT", "not-a-number") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") @@ -150,7 +148,6 @@ func TestLoad_FromYAML(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -196,7 +193,6 @@ func TestLoad_AvailabilityFromYAML(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -257,7 +253,6 @@ func TestLoad_EnvOverridesYAML(t *testing.T) { chdir(t, dir) t.Setenv("PEBBLE_PATH", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") yaml := ` @@ -310,7 +305,6 @@ func TestLoad_AvailabilityEnvOverridesYAML(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -373,7 +367,6 @@ func TestLoad_YAMLOverridesDefaults(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -412,7 +405,6 @@ func TestLoad_MissingYAML(t *testing.T) { t.Setenv("PEBBLE_PATH", "") t.Setenv("CALENDAR_URL", "") t.Setenv("GITHUB_TOKEN", "") - t.Setenv("GITHUB_USERNAME", "") t.Setenv("AVAILABILITY_IS_ENABLED", "") t.Setenv("AVAILABILITY_CALENDAR_URL", "") t.Setenv("AVAILABILITY_API_KEY", "") @@ -495,3 +487,58 @@ func TestAvailabilityValidate(t *testing.T) { } }) } + +func TestBuildValidateAndIntervalDuration(t *testing.T) { + t.Run("disabled", func(t *testing.T) { + cfg := BuildConfig{} + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + dur, err := cfg.IntervalDuration() + if err != nil { + t.Fatalf("IntervalDuration: %v", err) + } + if dur != 0 { + t.Fatalf("duration: got %v, want 0", dur) + } + }) + + t.Run("enabled valid", func(t *testing.T) { + cfg := BuildConfig{ + IsEnabled: true, + Interval: "10m", + CfDeployHook: "https://example.com/hook", + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + dur, err := cfg.IntervalDuration() + if err != nil { + t.Fatalf("IntervalDuration: %v", err) + } + if dur != 10*time.Minute { + t.Fatalf("duration: got %v, want 10m", dur) + } + }) + + t.Run("interval too short", func(t *testing.T) { + cfg := BuildConfig{ + IsEnabled: true, + Interval: "30s", + CfDeployHook: "https://example.com/hook", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for short interval") + } + }) + + t.Run("missing hook", func(t *testing.T) { + cfg := BuildConfig{ + IsEnabled: true, + Interval: "10m", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing deploy hook") + } + }) +} diff --git a/internal/feed/feed.go b/internal/feed/feed.go new file mode 100644 index 0000000..d79978e --- /dev/null +++ b/internal/feed/feed.go @@ -0,0 +1,34 @@ +package feed + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +// FetchBody fetches a feed body with the provided timeout. +func FetchBody(ctx context.Context, url string, timeout time.Duration) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch feed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch feed: unexpected status %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + return body, nil +} diff --git a/internal/github/target.go b/internal/github/target.go index f90ebdd..87dda29 100644 --- a/internal/github/target.go +++ b/internal/github/target.go @@ -35,17 +35,18 @@ func NewTarget(token string) *Target { // Sync implements target.Target. A nil status clears the GitHub user profile status. func (t *Target) Sync(ctx context.Context, st *target.Status) error { + status := st if st != nil { emoji, text := extractFirstEmoji(st.Text) if emoji != "" { - st.Emoji = emoji - st.Text = text + statusCopy := *st + statusCopy.Emoji = emoji + statusCopy.Text = text + status = &statusCopy } } - mutation := buildGraphQLMutation(st) - payload := map[string]string{"query": mutation} - + payload := buildGraphQLPayload(status) body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal graphql: %w", err) @@ -93,40 +94,35 @@ type graphQLResponse struct { Errors []struct { Message string `json:"message"` } `json:"errors"` - Data interface{} `json:"data"` } -// buildGraphQLMutation constructs the GraphQL mutation for changing user status. -// When st is nil, it clears the status by setting empty strings. -func buildGraphQLMutation(st *target.Status) string { - if st == nil { - // Clear status by setting message and emoji to empty strings (GitHub treats empty as clear) - return `mutation { changeUserStatus(input: { message: "", emoji: "" }) { status { message emoji expiresAt } } }` - } +type graphQLPayload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} - message := escapeGraphQLString(st.Text) - emoji := escapeGraphQLString(st.Emoji) +const changeUserStatusMutation = `mutation ChangeUserStatus($input: ChangeUserStatusInput!) { changeUserStatus(input: $input) { status { message emoji expiresAt } } }` - // Build the mutation with expiration if present - var expiresAtArg string - if !st.Expiration.IsZero() { - expiresAt := escapeGraphQLString(st.Expiration.UTC().Format(time.RFC3339)) - expiresAtArg = fmt.Sprintf(`, expiresAt: %s`, expiresAt) +// buildGraphQLPayload constructs the GraphQL request payload. Nil status clears +// the profile status by sending empty message and emoji values. +func buildGraphQLPayload(st *target.Status) graphQLPayload { + input := map[string]string{ + "message": "", + "emoji": "", + } + if st != nil { + input["message"] = st.Text + input["emoji"] = st.Emoji + if !st.Expiration.IsZero() { + input["expiresAt"] = st.Expiration.UTC().Format(time.RFC3339) + } + } + return graphQLPayload{ + Query: changeUserStatusMutation, + Variables: map[string]any{ + "input": input, + }, } - - return fmt.Sprintf( - `mutation { changeUserStatus(input: { message: %s, emoji: %s%s }) { status { message emoji expiresAt } } }`, - message, emoji, expiresAtArg, - ) -} - -// escapeGraphQLString escapes a string for use in a GraphQL query. -// It handles backslashes and quotes. -func escapeGraphQLString(s string) string { - // First escape backslashes, then escape quotes - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - return fmt.Sprintf(`"%s"`, s) } // extractFirstEmoji returns the first emoji found in the string and the remaining text. diff --git a/internal/github/target_test.go b/internal/github/target_test.go index 1e5d4b4..c839062 100644 --- a/internal/github/target_test.go +++ b/internal/github/target_test.go @@ -1,7 +1,10 @@ package github import ( + "context" "encoding/json" + "io" + "net/http" "strings" "testing" "time" @@ -9,103 +12,73 @@ import ( "github.com/gldraphael/status/internal/target" ) -func TestBuildGraphQLMutation_WithStatus(t *testing.T) { +func TestBuildGraphQLPayload_WithStatus(t *testing.T) { st := &target.Status{ Emoji: ":rocket:", Text: "Shipping a new feature", Expiration: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), } - mutation := buildGraphQLMutation(st) + payload := buildGraphQLPayload(st) - // Verify the mutation contains the expected components - if !strings.Contains(mutation, `changeUserStatus`) { + if !strings.Contains(payload.Query, `changeUserStatus`) { t.Errorf("mutation missing changeUserStatus") } - if !strings.Contains(mutation, `"Shipping a new feature"`) { - t.Errorf("mutation missing message: %s", mutation) + input := payload.Variables["input"].(map[string]string) + if input["message"] != "Shipping a new feature" { + t.Errorf("message: got %q", input["message"]) } - if !strings.Contains(mutation, `":rocket:"`) { - t.Errorf("mutation missing emoji: %s", mutation) + if input["emoji"] != ":rocket:" { + t.Errorf("emoji: got %q", input["emoji"]) } - if !strings.Contains(mutation, `2026-04-07T00:00:00Z`) { - t.Errorf("mutation missing expiry: %s", mutation) + if input["expiresAt"] != "2026-04-07T00:00:00Z" { + t.Errorf("expiresAt: got %q", input["expiresAt"]) } } -func TestBuildGraphQLMutation_NoExpiry(t *testing.T) { +func TestBuildGraphQLPayload_NoExpiry(t *testing.T) { st := &target.Status{ Emoji: ":calendar:", Text: "In a meeting", } - mutation := buildGraphQLMutation(st) + payload := buildGraphQLPayload(st) + input := payload.Variables["input"].(map[string]string) - // Should not have expiresAt in the input when expiration is zero - // Check only the input part (before the closing bracket) - inputPart := strings.Split(mutation, "}")[0] - if strings.Contains(inputPart, `expiresAt`) { - t.Errorf("mutation input should not have expiresAt: %s", mutation) + if _, ok := input["expiresAt"]; ok { + t.Errorf("input should not have expiresAt: %+v", input) } - if !strings.Contains(mutation, `"In a meeting"`) { - t.Errorf("mutation missing message") + if input["message"] != "In a meeting" { + t.Errorf("message: got %q", input["message"]) } } -func TestBuildGraphQLMutation_ClearsStatus(t *testing.T) { - mutation := buildGraphQLMutation(nil) +func TestBuildGraphQLPayload_ClearsStatus(t *testing.T) { + payload := buildGraphQLPayload(nil) + input := payload.Variables["input"].(map[string]string) - // Clearing status uses empty strings - if !strings.Contains(mutation, `message: ""`) { - t.Errorf("mutation should clear message: %s", mutation) + if input["message"] != "" { + t.Errorf("message: got %q, want empty", input["message"]) } - if !strings.Contains(mutation, `emoji: ""`) { - t.Errorf("mutation should clear emoji: %s", mutation) + if input["emoji"] != "" { + t.Errorf("emoji: got %q, want empty", input["emoji"]) } } -func TestEscapeGraphQLString_WithQuotes(t *testing.T) { - result := escapeGraphQLString(`It's "quoted"`) - expected := `"It's \"quoted\""` - if result != expected { - t.Errorf("escapeGraphQLString: got %q, want %q", result, expected) - } -} - -func TestEscapeGraphQLString_WithBackslash(t *testing.T) { - result := escapeGraphQLString(`C:\path\to\file`) - expected := `"C:\\path\\to\\file"` - if result != expected { - t.Errorf("escapeGraphQLString: got %q, want %q", result, expected) - } -} - -func TestEscapeGraphQLString_Empty(t *testing.T) { - result := escapeGraphQLString("") - expected := `""` - if result != expected { - t.Errorf("escapeGraphQLString: got %q, want %q", result, expected) - } -} - -func TestGraphQLMutation_IsValidJSON(t *testing.T) { +func TestGraphQLPayload_IsValidJSON(t *testing.T) { st := &target.Status{ Emoji: ":smile:", Text: "All systems operational", Expiration: time.Now().UTC().Add(2 * time.Hour), } - mutation := buildGraphQLMutation(st) - payload := map[string]string{"query": mutation} - - // Should be serializable to JSON + payload := buildGraphQLPayload(st) data, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal to json: %v", err) } - // Verify it can be unmarshaled - var decoded map[string]string + var decoded map[string]any if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("unmarshal from json: %v", err) } @@ -115,6 +88,34 @@ func TestGraphQLMutation_IsValidJSON(t *testing.T) { } } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestSync_DoesNotMutateStatusWhenExtractingEmoji(t *testing.T) { + tgt := NewTarget("test-token") + tgt.client = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"data":{"changeUserStatus":{"status":{}}}}`)), + Request: req, + }, nil + }), + } + + st := &target.Status{Emoji: ":calendar:", Text: "💡 Focusing"} + if err := tgt.Sync(context.Background(), st); err != nil { + t.Fatalf("Sync: %v", err) + } + if st.Emoji != ":calendar:" || st.Text != "💡 Focusing" { + t.Fatalf("status was mutated: %+v", st) + } +} + func TestNewTarget(t *testing.T) { tgt := NewTarget("test-token") if tgt.token != "test-token" { diff --git a/internal/poll/poll.go b/internal/poll/poll.go new file mode 100644 index 0000000..5104675 --- /dev/null +++ b/internal/poll/poll.go @@ -0,0 +1,27 @@ +package poll + +import ( + "context" + "time" +) + +// Every runs fn immediately, then at each interval until ctx is cancelled. +func Every(ctx context.Context, interval time.Duration, fn func() error, onError func(error, bool)) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + if err := fn(); err != nil { + onError(err, true) + } + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := fn(); err != nil { + onError(err, false) + } + } + } +} diff --git a/internal/store/keys.go b/internal/store/keys.go index 02e6da1..8eacb9d 100644 --- a/internal/store/keys.go +++ b/internal/store/keys.go @@ -4,10 +4,9 @@ package store // status → current status (single-tenant) // availability → cached availability calendar snapshot // availability_dirty → flag indicating availability changed since last deploy +// availability_last_deployed → availability entries JSON from the last successful deploy // availability_holidays → cached England bank holiday snapshot // event:{eventID} → Google Calendar event state -// channel:{channelID} → push notification channel registration -// sync:{calendarID} → incremental sync token func statusKey() []byte { return []byte("status") @@ -37,14 +36,6 @@ func eventKeyPrefix() []byte { return []byte("event:") } -func channelKey(channelID string) []byte { - return []byte("channel:" + channelID) -} - -func syncTokenKey(calendarID string) []byte { - return []byte("sync:" + calendarID) -} - // prefixUpperBound returns the smallest key that is lexicographically greater // than all keys with the given prefix, for use as an iterator upper bound. func prefixUpperBound(prefix []byte) []byte { diff --git a/internal/store/store.go b/internal/store/store.go index 1befdd3..1f36bf7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -39,15 +39,6 @@ type HolidaySnapshot struct { FetchedAt time.Time `json:"fetched_at"` } -// Channel represents a registered Google Calendar push notification channel. -type Channel struct { - ID string `json:"id"` - ResourceID string `json:"resource_id"` - CalendarID string `json:"calendar_id"` - UserID string `json:"user_id"` - Expiry time.Time `json:"expiry"` -} - // Store wraps a Pebble database. type Store struct { db *pebble.DB @@ -67,191 +58,143 @@ func (s *Store) Close() error { return s.db.Close() } -// GetStatus returns the current status (O(1) lookup). -func (s *Store) GetStatus() (*Status, bool, error) { - data, closer, err := s.db.Get(statusKey()) +func (s *Store) getBytes(key []byte, label string) ([]byte, bool, error) { + data, closer, err := s.db.Get(key) if errors.Is(err, pebble.ErrNotFound) { return nil, false, nil } if err != nil { - return nil, false, fmt.Errorf("get status: %w", err) + return nil, false, fmt.Errorf("get %s: %w", label, err) } defer closer.Close() - var st Status - if err := json.Unmarshal(data, &st); err != nil { - return nil, false, fmt.Errorf("unmarshal status: %w", err) - } - return &st, true, nil + buf := make([]byte, len(data)) + copy(buf, data) + return buf, true, nil } -// SetStatus persists the current status. -func (s *Store) SetStatus(st *Status) error { - data, err := json.Marshal(st) - if err != nil { - return fmt.Errorf("marshal status: %w", err) - } - if err := s.db.Set(statusKey(), data, pebble.Sync); err != nil { - return fmt.Errorf("set status: %w", err) +func (s *Store) setBytes(key, data []byte, label string) error { + if err := s.db.Set(key, data, pebble.Sync); err != nil { + return fmt.Errorf("set %s: %w", label, err) } return nil } -// DeleteStatus removes the stored status. -func (s *Store) DeleteStatus() error { - err := s.db.Delete(statusKey(), pebble.Sync) +func (s *Store) deleteKey(key []byte, label string) error { + err := s.db.Delete(key, pebble.Sync) if err != nil && !errors.Is(err, pebble.ErrNotFound) { - return fmt.Errorf("delete status: %w", err) + return fmt.Errorf("delete %s: %w", label, err) } return nil } -// GetAvailabilitySnapshot returns the stored availability calendar snapshot. -func (s *Store) GetAvailabilitySnapshot() (*AvailabilitySnapshot, bool, error) { - data, closer, err := s.db.Get(availabilityKey()) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil +func (s *Store) getJSON(key []byte, value any, label string) (bool, error) { + data, ok, err := s.getBytes(key, label) + if err != nil || !ok { + return ok, err } + if err := json.Unmarshal(data, value); err != nil { + return false, fmt.Errorf("unmarshal %s: %w", label, err) + } + return true, nil +} + +func (s *Store) setJSON(key []byte, value any, label string) error { + data, err := json.Marshal(value) if err != nil { - return nil, false, fmt.Errorf("get availability snapshot: %w", err) + return fmt.Errorf("marshal %s: %w", label, err) } - defer closer.Close() + return s.setBytes(key, data, label) +} +// GetStatus returns the current status (O(1) lookup). +func (s *Store) GetStatus() (*Status, bool, error) { + var st Status + ok, err := s.getJSON(statusKey(), &st, "status") + if err != nil || !ok { + return nil, ok, err + } + return &st, true, nil +} + +// SetStatus persists the current status. +func (s *Store) SetStatus(st *Status) error { + return s.setJSON(statusKey(), st, "status") +} + +// DeleteStatus removes the stored status. +func (s *Store) DeleteStatus() error { + return s.deleteKey(statusKey(), "status") +} + +// GetAvailabilitySnapshot returns the stored availability calendar snapshot. +func (s *Store) GetAvailabilitySnapshot() (*AvailabilitySnapshot, bool, error) { var snap AvailabilitySnapshot - if err := json.Unmarshal(data, &snap); err != nil { - return nil, false, fmt.Errorf("unmarshal availability snapshot: %w", err) + ok, err := s.getJSON(availabilityKey(), &snap, "availability snapshot") + if err != nil || !ok { + return nil, ok, err } return &snap, true, nil } // SetAvailabilitySnapshot persists the latest availability calendar snapshot. func (s *Store) SetAvailabilitySnapshot(snap *AvailabilitySnapshot) error { - data, err := json.Marshal(snap) - if err != nil { - return fmt.Errorf("marshal availability snapshot: %w", err) - } - if err := s.db.Set(availabilityKey(), data, pebble.Sync); err != nil { - return fmt.Errorf("set availability snapshot: %w", err) - } - return nil + return s.setJSON(availabilityKey(), snap, "availability snapshot") } // GetAvailabilityDirty returns the pending availability entries JSON if the calendar has changed since the last deployment. func (s *Store) GetAvailabilityDirty() ([]byte, bool, error) { - data, closer, err := s.db.Get(availabilityDirtyKey()) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("get availability dirty: %w", err) - } - defer closer.Close() - - buf := make([]byte, len(data)) - copy(buf, data) - return buf, true, nil + return s.getBytes(availabilityDirtyKey(), "availability dirty") } // SetAvailabilityDirty persists the pending availability entries JSON. func (s *Store) SetAvailabilityDirty(data []byte) error { - if err := s.db.Set(availabilityDirtyKey(), data, pebble.Sync); err != nil { - return fmt.Errorf("set availability dirty: %w", err) - } - return nil + return s.setBytes(availabilityDirtyKey(), data, "availability dirty") } // ClearAvailabilityDirty removes the availability dirty flag. func (s *Store) ClearAvailabilityDirty() error { - err := s.db.Delete(availabilityDirtyKey(), pebble.Sync) - if err != nil && !errors.Is(err, pebble.ErrNotFound) { - return fmt.Errorf("delete availability dirty: %w", err) - } - return nil + return s.deleteKey(availabilityDirtyKey(), "availability dirty") } // GetLastDeployedAvailability returns the availability entries JSON from the last successful deployment. func (s *Store) GetLastDeployedAvailability() ([]byte, bool, error) { - data, closer, err := s.db.Get(availabilityLastDeployedKey()) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("get availability last deployed: %w", err) - } - defer closer.Close() - - // Return a copy since the closer will invalidate the slice. - buf := make([]byte, len(data)) - copy(buf, data) - return buf, true, nil + return s.getBytes(availabilityLastDeployedKey(), "availability last deployed") } // SetLastDeployedAvailability persists the availability entries JSON from a successful deployment. func (s *Store) SetLastDeployedAvailability(data []byte) error { - if err := s.db.Set(availabilityLastDeployedKey(), data, pebble.Sync); err != nil { - return fmt.Errorf("set availability last deployed: %w", err) - } - return nil + return s.setBytes(availabilityLastDeployedKey(), data, "availability last deployed") } // GetHolidaySnapshot returns the stored bank holiday snapshot. func (s *Store) GetHolidaySnapshot() (*HolidaySnapshot, bool, error) { - data, closer, err := s.db.Get(availabilityHolidaysKey()) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("get holiday snapshot: %w", err) - } - defer closer.Close() - var snap HolidaySnapshot - if err := json.Unmarshal(data, &snap); err != nil { - return nil, false, fmt.Errorf("unmarshal holiday snapshot: %w", err) + ok, err := s.getJSON(availabilityHolidaysKey(), &snap, "holiday snapshot") + if err != nil || !ok { + return nil, ok, err } return &snap, true, nil } // SetHolidaySnapshot persists the latest bank holiday snapshot. func (s *Store) SetHolidaySnapshot(snap *HolidaySnapshot) error { - data, err := json.Marshal(snap) - if err != nil { - return fmt.Errorf("marshal holiday snapshot: %w", err) - } - if err := s.db.Set(availabilityHolidaysKey(), data, pebble.Sync); err != nil { - return fmt.Errorf("set holiday snapshot: %w", err) - } - return nil + return s.setJSON(availabilityHolidaysKey(), snap, "holiday snapshot") } // GetEvent retrieves a stored calendar event. func (s *Store) GetEvent(eventID string) (*Event, bool, error) { - data, closer, err := s.db.Get(eventKey(eventID)) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("get event: %w", err) - } - defer closer.Close() - var ev Event - if err := json.Unmarshal(data, &ev); err != nil { - return nil, false, fmt.Errorf("unmarshal event: %w", err) + ok, err := s.getJSON(eventKey(eventID), &ev, "event") + if err != nil || !ok { + return nil, ok, err } return &ev, true, nil } // SetEvent persists a calendar event. func (s *Store) SetEvent(ev *Event) error { - data, err := json.Marshal(ev) - if err != nil { - return fmt.Errorf("marshal event: %w", err) - } - if err := s.db.Set(eventKey(ev.ID), data, pebble.Sync); err != nil { - return fmt.Errorf("set event: %w", err) - } - return nil + return s.setJSON(eventKey(ev.ID), ev, "event") } // ListActiveEvents returns events that overlap with now @@ -282,54 +225,3 @@ func (s *Store) ListActiveEvents(now time.Time) ([]*Event, error) { } return active, nil } - -// GetChannel retrieves a registered push notification channel. -func (s *Store) GetChannel(channelID string) (*Channel, bool, error) { - data, closer, err := s.db.Get(channelKey(channelID)) - if errors.Is(err, pebble.ErrNotFound) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("get channel: %w", err) - } - defer closer.Close() - - var ch Channel - if err := json.Unmarshal(data, &ch); err != nil { - return nil, false, fmt.Errorf("unmarshal channel: %w", err) - } - return &ch, true, nil -} - -// SetChannel persists a push notification channel registration. -func (s *Store) SetChannel(ch *Channel) error { - data, err := json.Marshal(ch) - if err != nil { - return fmt.Errorf("marshal channel: %w", err) - } - if err := s.db.Set(channelKey(ch.ID), data, pebble.Sync); err != nil { - return fmt.Errorf("set channel: %w", err) - } - return nil -} - -// GetSyncToken returns the stored incremental sync token for a calendar. -func (s *Store) GetSyncToken(calendarID string) (string, bool, error) { - data, closer, err := s.db.Get(syncTokenKey(calendarID)) - if errors.Is(err, pebble.ErrNotFound) { - return "", false, nil - } - if err != nil { - return "", false, fmt.Errorf("get sync token: %w", err) - } - defer closer.Close() - return string(data), true, nil -} - -// SetSyncToken persists the incremental sync token for a calendar. -func (s *Store) SetSyncToken(calendarID, token string) error { - if err := s.db.Set(syncTokenKey(calendarID), []byte(token), pebble.Sync); err != nil { - return fmt.Errorf("set sync token: %w", err) - } - return nil -} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a7c119a..131a46e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -157,6 +157,58 @@ func TestHolidaySnapshot_SetGet(t *testing.T) { } } +func TestAvailabilityDeploymentState_SetGetClear(t *testing.T) { + st := newTestStore(t) + + _, ok, err := st.GetAvailabilityDirty() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected dirty state not found before set") + } + + dirty := []byte(`[{"date":"2026-04-06","block":"Morning"}]`) + if err := st.SetAvailabilityDirty(dirty); err != nil { + t.Fatal(err) + } + got, ok, err := st.GetAvailabilityDirty() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected dirty state after set") + } + if string(got) != string(dirty) { + t.Fatalf("dirty mismatch: got %s, want %s", got, dirty) + } + + if err := st.SetLastDeployedAvailability(got); err != nil { + t.Fatal(err) + } + last, ok, err := st.GetLastDeployedAvailability() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected last deployed state after set") + } + if string(last) != string(dirty) { + t.Fatalf("last deployed mismatch: got %s, want %s", last, dirty) + } + + if err := st.ClearAvailabilityDirty(); err != nil { + t.Fatal(err) + } + _, ok, err = st.GetAvailabilityDirty() + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected dirty state to be cleared") + } +} + func TestEvent_SetGet(t *testing.T) { st := newTestStore(t) @@ -294,73 +346,3 @@ func TestListActiveEvents_Empty(t *testing.T) { t.Errorf("expected 0 active events, got %d", len(active)) } } - -func TestChannel_SetGet(t *testing.T) { - st := newTestStore(t) - - _, ok, err := st.GetChannel("ch1") - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("expected not found before set") - } - - want := &store.Channel{ - ID: "ch1", - ResourceID: "res1", - CalendarID: "primary", - UserID: "alice", - Expiry: time.Now().Add(7 * 24 * time.Hour).Truncate(time.Millisecond), - } - if err := st.SetChannel(want); err != nil { - t.Fatal(err) - } - - got, ok, err := st.GetChannel("ch1") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected found after set") - } - if got.ID != want.ID || got.ResourceID != want.ResourceID || got.UserID != want.UserID { - t.Errorf("channel mismatch: got %+v, want %+v", got, want) - } -} - -func TestSyncToken_SetGet(t *testing.T) { - st := newTestStore(t) - - _, ok, err := st.GetSyncToken("primary") - if err != nil { - t.Fatal(err) - } - if ok { - t.Fatal("expected not found before set") - } - - if err := st.SetSyncToken("primary", "token-abc123"); err != nil { - t.Fatal(err) - } - - got, ok, err := st.GetSyncToken("primary") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("expected found after set") - } - if got != "token-abc123" { - t.Errorf("got token %q, want %q", got, "token-abc123") - } - - // Overwrite. - if err := st.SetSyncToken("primary", "token-xyz789"); err != nil { - t.Fatal(err) - } - got, _, _ = st.GetSyncToken("primary") - if got != "token-xyz789" { - t.Errorf("got token %q, want %q", got, "token-xyz789") - } -} diff --git a/main.go b/main.go index c1b90a3..4d9d225 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,8 @@ func run(logger zerolog.Logger) error { if err := cfg.Availability.Validate(); err != nil { return fmt.Errorf("validate availability config: %w", err) } - if err := cfg.Build.Validate(); err != nil { + buildInterval, err := cfg.Build.IntervalDuration() + if err != nil { return fmt.Errorf("validate build config: %w", err) } @@ -60,10 +61,6 @@ func run(logger zerolog.Logger) error { targets := buildTargets(cfg) syncer := calendar.NewSyncer(st, calClient, targets, logger) - var ( - availabilitySyncer *availability.Syncer - availabilityProvider *availability.Provider - ) // Health-check endpoint. mux := http.NewServeMux() @@ -71,30 +68,9 @@ func run(logger zerolog.Logger) error { w.WriteHeader(http.StatusOK) }) - if cfg.Availability.IsEnabled { - workingHours, err := availability.ParseWorkingHours(cfg.Availability.WorkingHours.Start, cfg.Availability.WorkingHours.End) - if err != nil { - return fmt.Errorf("parse availability working hours: %w", err) - } - - availabilityClient, err := availability.NewClient(cfg.Availability.CalendarURL) - if err != nil { - return fmt.Errorf("create availability client: %w", err) - } - availabilityBlocks, err := availability.ParseBlocks(cfg.Availability.Blocks) - if err != nil { - return fmt.Errorf("parse availability blocks: %w", err) - } - - if cfg.Availability.ExcludeEnglandBankHolidays { - if err := availability.SyncHolidaySnapshot(ctx, st, availability.NewHolidayClient(), logger); err != nil { - return fmt.Errorf("seed bank holidays: %w", err) - } - } - - availabilityProvider = availability.NewProvider(st, availabilityBlocks, workingHours, cfg.Availability.ExcludeEnglandBankHolidays) - availabilitySyncer = availability.NewSyncer(st, availabilityProvider, availabilityClient, logger) - mux.Handle("GET /api/availability", availability.NewHandler(availabilityProvider, cfg.Availability.APIKey, logger)) + availabilitySyncer, err := registerAvailability(ctx, cfg.Availability, st, mux, logger) + if err != nil { + return err } // Start the sync loops only after all startup validation succeeds. @@ -112,24 +88,55 @@ func run(logger zerolog.Logger) error { } // Start deploy loop if enabled. - if cfg.Build.IsEnabled { - interval, err := time.ParseDuration(cfg.Build.Interval) - if err != nil { - return fmt.Errorf("parse build.interval: %w", err) - } - client := deploy.NewHookClient(cfg.Build.CfDeployHook) - deployer := deploy.NewDeployer(client, st, logger) - go func() { - if err := deployer.Run(ctx, interval); err != nil { - logger.Error().Err(err).Msg("build deploy loop exited") - } - }() - } + startDeployLoop(ctx, cfg.Build, buildInterval, st, logger) srv := server.New(cfg.Port, mux, logger) return srv.Start(ctx) } +func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st *store.Store, mux *http.ServeMux, logger zerolog.Logger) (*availability.Syncer, error) { + if !cfg.IsEnabled { + return nil, nil + } + + workingHours, err := availability.ParseWorkingHours(cfg.WorkingHours.Start, cfg.WorkingHours.End) + if err != nil { + return nil, fmt.Errorf("parse availability working hours: %w", err) + } + availabilityBlocks, err := availability.ParseBlocks(cfg.Blocks) + if err != nil { + return nil, fmt.Errorf("parse availability blocks: %w", err) + } + availabilityClient, err := availability.NewClient(cfg.CalendarURL) + if err != nil { + return nil, fmt.Errorf("create availability client: %w", err) + } + + if cfg.ExcludeEnglandBankHolidays { + if err := availability.SyncHolidaySnapshot(ctx, st, availability.NewHolidayClient(), logger); err != nil { + return nil, fmt.Errorf("seed bank holidays: %w", err) + } + } + + provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.ExcludeEnglandBankHolidays) + mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.APIKey, logger)) + return availability.NewSyncer(st, provider, availabilityClient, logger), nil +} + +func startDeployLoop(ctx context.Context, cfg config.BuildConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { + if !cfg.IsEnabled { + return + } + + client := deploy.NewHookClient(cfg.CfDeployHook) + deployer := deploy.NewDeployer(client, st, logger) + go func() { + if err := deployer.Run(ctx, interval); err != nil { + logger.Error().Err(err).Msg("build deploy loop exited") + } + }() +} + // buildTargets constructs the list of enabled status targets from config. // A target is enabled when its token is non-empty. func buildTargets(cfg *config.Config) []target.Target { From 9a17b8166605d09b037e4b949c0732a1fd0cc251 Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Thu, 14 May 2026 21:32:06 +0100 Subject: [PATCH 2/3] More cleanup --- internal/availability/availability.go | 77 +++-------- internal/availability/availability_test.go | 24 ++-- internal/availability/client.go | 30 ----- internal/availability/holiday.go | 12 +- internal/availability/syncer.go | 4 +- internal/calendar/client.go | 23 +++- internal/calendar/handler.go | 4 +- internal/calendar/ical.go | 37 +---- internal/calendar/ical_test.go | 43 +++--- internal/config/config.go | 21 +-- internal/config/config_test.go | 150 +++++++-------------- internal/deploy/syncer.go | 13 +- internal/feed/feed.go | 26 +++- internal/github/target.go | 31 +++-- internal/github/target_test.go | 36 ++--- internal/store/store.go | 2 +- internal/timeutil/timeutil.go | 51 +++++++ internal/timeutil/timeutil_test.go | 50 +++++++ main.go | 23 +++- 19 files changed, 327 insertions(+), 330 deletions(-) delete mode 100644 internal/availability/client.go create mode 100644 internal/timeutil/timeutil.go create mode 100644 internal/timeutil/timeutil_test.go diff --git a/internal/availability/availability.go b/internal/availability/availability.go index b795a27..4a228d0 100644 --- a/internal/availability/availability.go +++ b/internal/availability/availability.go @@ -4,12 +4,11 @@ import ( "encoding/json" "errors" "fmt" - "strings" "time" "github.com/gldraphael/status/internal/calendar" - "github.com/gldraphael/status/internal/config" "github.com/gldraphael/status/internal/store" + "github.com/gldraphael/status/internal/timeutil" ) var ( @@ -45,30 +44,22 @@ type ComputeOptions struct { Now time.Time } -// ParseBlocks converts configured blocks into runtime blocks. -func ParseBlocks(blocks []config.AvailabilityBlockConfig) ([]Block, error) { - parsed := make([]Block, 0, len(blocks)) - for i, block := range blocks { - start, err := parseClock(block.Start) - if err != nil { - return nil, fmt.Errorf("availability.blocks[%d].start: %w", i, err) - } - end, err := parseClock(block.End) - if err != nil { - return nil, fmt.Errorf("availability.blocks[%d].end: %w", i, err) - } - parsed = append(parsed, Block{ - Name: block.Name, - Start: start, - End: end, - }) +// ParseBlock converts a named clock range into a runtime availability block. +func ParseBlock(name, startValue, endValue string) (Block, error) { + start, err := timeutil.ParseClock(startValue) + if err != nil { + return Block{}, fmt.Errorf("start: %w", err) + } + end, err := timeutil.ParseClock(endValue) + if err != nil { + return Block{}, fmt.Errorf("end: %w", err) } - return parsed, nil + return Block{Name: name, Start: start, End: end}, nil } // ParseWorkingHours converts the configured weekday working-hours window. func ParseWorkingHours(startValue, endValue string) (WorkingHours, error) { - start, end, err := parseClockRange(startValue, endValue) + start, end, err := timeutil.ParseClockRange(startValue, endValue) if err != nil { return WorkingHours{}, err } @@ -145,7 +136,7 @@ func Compute(body string, timezone string, blocks []Block, opts ComputeOptions) opts.Now = time.Now() } - loc := loadLocation(timezone) + loc := timeutil.LoadLocation(timezone) nowLocal := opts.Now.In(loc) dayStart := time.Date(nowLocal.Year(), nowLocal.Month(), nowLocal.Day(), 0, 0, 0, 0, loc) windowEnd := dayStart.AddDate(0, 0, 10) @@ -219,7 +210,7 @@ func firstFreeBlock( if isToday && blockStart.Before(now) { continue } - if applyWorkingHours && overlaps(blockStart, blockEnd, workingStart, workingEnd) { + if applyWorkingHours && timeutil.Overlaps(blockStart, blockEnd, workingStart, workingEnd) { continue } if !blockIsFree(events, blockStart, blockEnd, loc) { @@ -242,40 +233,6 @@ func isExcludedHoliday(day time.Time, holidaySet map[string]struct{}, enabled bo return ok } -func parseClockRange(startValue, endValue string) (time.Duration, time.Duration, error) { - start, err := parseClock(strings.TrimSpace(startValue)) - if err != nil { - return 0, 0, err - } - end, err := parseClock(strings.TrimSpace(endValue)) - if err != nil { - return 0, 0, err - } - if end <= start { - return 0, 0, fmt.Errorf("end must be after start") - } - return start, end, nil -} - -func parseClock(value string) (time.Duration, error) { - t, err := time.Parse("15:04", value) - if err != nil { - return 0, err - } - return time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute, nil -} - -func loadLocation(name string) *time.Location { - if name == "" { - return time.UTC - } - loc, err := time.LoadLocation(name) - if err != nil { - return time.UTC - } - return loc -} - func blockIsFree(events []calendar.ParsedEvent, start, end time.Time, loc *time.Location) bool { for _, event := range events { if event.Cancelled || !event.Busy { @@ -284,13 +241,9 @@ func blockIsFree(events []calendar.ParsedEvent, start, end time.Time, loc *time. eventStart := event.StartTime.In(loc) eventEnd := event.EndTime.In(loc) - if eventStart.Before(end) && eventEnd.After(start) { + if timeutil.Overlaps(eventStart, eventEnd, start, end) { return false } } return true } - -func overlaps(start, end, windowStart, windowEnd time.Time) bool { - return start.Before(windowEnd) && end.After(windowStart) -} diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go index 86f30fa..1d63069 100644 --- a/internal/availability/availability_test.go +++ b/internal/availability/availability_test.go @@ -10,7 +10,6 @@ import ( "github.com/rs/zerolog" - "github.com/gldraphael/status/internal/config" "github.com/gldraphael/status/internal/store" ) @@ -26,13 +25,22 @@ func newTestStore(t *testing.T) *store.Store { func testBlocks(t *testing.T) []Block { t.Helper() - blocks, err := ParseBlocks([]config.AvailabilityBlockConfig{ - {Name: "Morning", Start: "09:00", End: "12:00"}, - {Name: "Afternoon", Start: "12:00", End: "16:30"}, - {Name: "Evening", Start: "17:30", End: "22:00"}, - }) - if err != nil { - t.Fatalf("ParseBlocks: %v", err) + specs := []struct { + name string + start string + end string + }{ + {name: "Morning", start: "09:00", end: "12:00"}, + {name: "Afternoon", start: "12:00", end: "16:30"}, + {name: "Evening", start: "17:30", end: "22:00"}, + } + blocks := make([]Block, 0, len(specs)) + for _, spec := range specs { + block, err := ParseBlock(spec.name, spec.start, spec.end) + if err != nil { + t.Fatalf("ParseBlock(%q): %v", spec.name, err) + } + blocks = append(blocks, block) } return blocks } diff --git a/internal/availability/client.go b/internal/availability/client.go deleted file mode 100644 index eaff774..0000000 --- a/internal/availability/client.go +++ /dev/null @@ -1,30 +0,0 @@ -package availability - -import ( - "context" - "fmt" - - "github.com/gldraphael/status/internal/calendar" -) - -// Client fetches the raw availability calendar feed. -type Client struct { - calendarURL string -} - -// NewClient creates a Client for the given iCal URL. -func NewClient(calendarURL string) (*Client, error) { - if calendarURL == "" { - return nil, fmt.Errorf("calendar URL is required") - } - return &Client{calendarURL: calendarURL}, nil -} - -// Fetch returns the raw iCal body. -func (c *Client) Fetch(ctx context.Context) ([]byte, error) { - body, err := calendar.FetchICalendarBody(ctx, c.calendarURL) - if err != nil { - return nil, fmt.Errorf("fetch availability calendar: %w", err) - } - return body, nil -} diff --git a/internal/availability/holiday.go b/internal/availability/holiday.go index 521e6d2..2c63494 100644 --- a/internal/availability/holiday.go +++ b/internal/availability/holiday.go @@ -16,17 +16,21 @@ const englandBankHolidaysURL = "https://www.gov.uk/bank-holidays.json" // HolidayClient fetches the GOV.UK bank-holidays feed. type HolidayClient struct { - url string + feed *feed.Client } // NewHolidayClient creates a client for the fixed GOV.UK bank-holidays feed. -func NewHolidayClient() *HolidayClient { - return &HolidayClient{url: englandBankHolidaysURL} +func NewHolidayClient() (*HolidayClient, error) { + client, err := feed.NewClient(englandBankHolidaysURL, 30*time.Second) + if err != nil { + return nil, err + } + return &HolidayClient{feed: client}, nil } // Fetch returns the raw bank-holidays JSON body. func (c *HolidayClient) Fetch(ctx context.Context) ([]byte, error) { - body, err := feed.FetchBody(ctx, c.url, 30*time.Second) + body, err := c.feed.Fetch(ctx) if err != nil { return nil, fmt.Errorf("fetch bank holidays: %w", err) } diff --git a/internal/availability/syncer.go b/internal/availability/syncer.go index 2dd120e..8dbac98 100644 --- a/internal/availability/syncer.go +++ b/internal/availability/syncer.go @@ -23,6 +23,7 @@ type Syncer struct { provider *Provider cal feedClient logger zerolog.Logger + nowFunc func() time.Time } // NewSyncer creates a new Syncer. @@ -32,6 +33,7 @@ func NewSyncer(st *store.Store, provider *Provider, cal feedClient, logger zerol provider: provider, cal: cal, logger: logger, + nowFunc: time.Now, } } @@ -61,7 +63,7 @@ func (s *Syncer) syncOnce(ctx context.Context) error { snap := &store.AvailabilitySnapshot{ Body: string(body), Timezone: timezone, - FetchedAt: time.Now().UTC(), + FetchedAt: s.nowFunc().UTC(), } if err := s.store.SetAvailabilitySnapshot(snap); err != nil { return fmt.Errorf("store availability snapshot: %w", err) diff --git a/internal/calendar/client.go b/internal/calendar/client.go index 5401992..80be948 100644 --- a/internal/calendar/client.go +++ b/internal/calendar/client.go @@ -4,11 +4,14 @@ import ( "context" "fmt" "time" + + "github.com/gldraphael/status/internal/feed" ) // Client fetches calendar events from an iCal URL. type Client struct { - calendarURL string + feed *feed.Client + nowFunc func() time.Time } // NewClient creates a Client for the given iCal URL. @@ -16,7 +19,11 @@ func NewClient(calendarURL string) (*Client, error) { if calendarURL == "" { return nil, fmt.Errorf("calendar URL is required") } - return &Client{calendarURL: calendarURL}, nil + feedClient, err := feed.NewClient(calendarURL, 30*time.Second) + if err != nil { + return nil, err + } + return &Client{feed: feedClient, nowFunc: time.Now}, nil } // ChangedEvent is a calendar event returned from FetchEvents. @@ -30,13 +37,19 @@ type ChangedEvent struct { // FetchEvents fetches all events from the iCal URL. func (c *Client) FetchEvents(ctx context.Context) ([]ChangedEvent, error) { - parsed, err := FetchAndParseICalendar(ctx, c.calendarURL) + body, err := c.feed.Fetch(ctx) + if err != nil { + return nil, fmt.Errorf("fetch calendar: %w", err) + } + + now := c.nowFunc() + parsed, err := ParseICalendar(body, now.Add(-24*time.Hour), now.Add(24*time.Hour)) if err != nil { return nil, fmt.Errorf("fetch events: %w", err) } - events := make([]ChangedEvent, len(parsed)) - for i, p := range parsed { + events := make([]ChangedEvent, len(parsed.Events)) + for i, p := range parsed.Events { events[i] = ChangedEvent{ ID: p.ID, Summary: p.Summary, diff --git a/internal/calendar/handler.go b/internal/calendar/handler.go index 65ffe94..6df563b 100644 --- a/internal/calendar/handler.go +++ b/internal/calendar/handler.go @@ -24,6 +24,7 @@ type Syncer struct { cal calendarClient targets []target.Target logger zerolog.Logger + nowFunc func() time.Time } // NewSyncer creates a new Syncer. @@ -33,6 +34,7 @@ func NewSyncer(st *store.Store, cal calendarClient, targets []target.Target, log cal: cal, targets: targets, logger: logger, + nowFunc: time.Now, } } @@ -76,7 +78,7 @@ func (s *Syncer) syncOnce(ctx context.Context) error { // syncStatus computes and syncs the current status to all targets. func (s *Syncer) syncStatus(ctx context.Context) error { - now := time.Now() + now := s.nowFunc() active, err := s.store.ListActiveEvents(now) if err != nil { return fmt.Errorf("list active events: %w", err) diff --git a/internal/calendar/ical.go b/internal/calendar/ical.go index 0e7057b..473e3ac 100644 --- a/internal/calendar/ical.go +++ b/internal/calendar/ical.go @@ -1,7 +1,6 @@ package calendar import ( - "context" "fmt" "strings" "time" @@ -9,7 +8,7 @@ import ( ics "github.com/arran4/golang-ical" "github.com/teambition/rrule-go" - "github.com/gldraphael/status/internal/feed" + "github.com/gldraphael/status/internal/timeutil" ) // ParsedEvent is an event extracted from an iCal file. @@ -28,30 +27,6 @@ type ParsedCalendar struct { Events []ParsedEvent } -// FetchICalendarBody fetches the raw iCal body from the given URL. -func FetchICalendarBody(ctx context.Context, calendarURL string) ([]byte, error) { - body, err := feed.FetchBody(ctx, calendarURL, 30*time.Second) - if err != nil { - return nil, fmt.Errorf("fetch calendar: %w", err) - } - return body, nil -} - -// FetchAndParseICalendar fetches and parses an iCal file from the given URL. -func FetchAndParseICalendar(ctx context.Context, calendarURL string) ([]ParsedEvent, error) { - body, err := FetchICalendarBody(ctx, calendarURL) - if err != nil { - return nil, err - } - - now := time.Now() - parsed, err := ParseICalendar(body, now.Add(-24*time.Hour), now.Add(24*time.Hour)) - if err != nil { - return nil, err - } - return parsed.Events, nil -} - // ParseICalendar parses an iCal stream and expands recurring events within the // requested time window. func ParseICalendar(data []byte, windowStart, windowEnd time.Time) (*ParsedCalendar, error) { @@ -135,7 +110,7 @@ func ParseICalendar(data []byte, windowStart, windowEnd time.Time) (*ParsedCalen for _, base := range baseEvents { if base.rrule == "" { - if overlaps(base.startTime, base.endTime, windowStart, windowEnd) { + if timeutil.Overlaps(base.startTime, base.endTime, windowStart, windowEnd) { parsed.Events = append(parsed.Events, ParsedEvent{ ID: base.id, Summary: base.summary, @@ -157,7 +132,7 @@ func ParseICalendar(data []byte, windowStart, windowEnd time.Time) (*ParsedCalen duration := base.endTime.Sub(base.startTime) for _, inst := range instances { endAt := inst.Add(duration) - if !overlaps(inst, endAt, windowStart, windowEnd) { + if !timeutil.Overlaps(inst, endAt, windowStart, windowEnd) { continue } parsed.Events = append(parsed.Events, ParsedEvent{ @@ -173,7 +148,7 @@ func ParseICalendar(data []byte, windowStart, windowEnd time.Time) (*ParsedCalen } } - if overlaps(base.startTime, base.endTime, windowStart, windowEnd) { + if timeutil.Overlaps(base.startTime, base.endTime, windowStart, windowEnd) { parsed.Events = append(parsed.Events, ParsedEvent{ ID: base.id, Summary: base.summary, @@ -208,7 +183,3 @@ func calendarTimezone(cal *ics.Calendar) string { } return "UTC" } - -func overlaps(start, end, windowStart, windowEnd time.Time) bool { - return start.Before(windowEnd) && end.After(windowStart) -} diff --git a/internal/calendar/ical_test.go b/internal/calendar/ical_test.go index 43e7c4c..2f1073c 100644 --- a/internal/calendar/ical_test.go +++ b/internal/calendar/ical_test.go @@ -17,6 +17,19 @@ func parseEvents(data string, now time.Time) ([]ParsedEvent, error) { return parsed.Events, nil } +func fetchEvents(t *testing.T, calendarURL string, now time.Time) ([]ChangedEvent, error) { + t.Helper() + client, err := NewClient(calendarURL) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + client.nowFunc = func() time.Time { return now } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return client.FetchEvents(ctx) +} + func TestParseICalendar_BasicEvent(t *testing.T) { icalData := `BEGIN:VCALENDAR PRODID:-//Test//Test Calendar//EN @@ -136,8 +149,8 @@ END:VCALENDAR` } } -func TestFetchAndParseICalendar_WithHTTPServer(t *testing.T) { - now := time.Now().UTC().Truncate(time.Minute) +func TestClientFetchEvents_WithHTTPServer(t *testing.T) { + now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) start := now.Add(-time.Hour).Format("20060102T150405Z") end := now.Add(time.Hour).Format("20060102T150405Z") icalData := "BEGIN:VCALENDAR\n" + @@ -162,12 +175,9 @@ func TestFetchAndParseICalendar_WithHTTPServer(t *testing.T) { })) defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - events, err := FetchAndParseICalendar(ctx, server.URL) + events, err := fetchEvents(t, server.URL, now) if err != nil { - t.Fatalf("FetchAndParseICalendar: %v", err) + t.Fatalf("FetchEvents: %v", err) } if len(events) != 1 { @@ -179,16 +189,13 @@ func TestFetchAndParseICalendar_WithHTTPServer(t *testing.T) { } } -func TestFetchAndParseICalendar_404Error(t *testing.T) { +func TestClientFetchEvents_404Error(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, err := FetchAndParseICalendar(ctx, server.URL) + _, err := fetchEvents(t, server.URL, time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC)) if err == nil { t.Error("expected error for 404 response") } @@ -197,9 +204,8 @@ func TestFetchAndParseICalendar_404Error(t *testing.T) { } } -func TestFetchAndParseICalendar_GoogleCalendarFormat(t *testing.T) { - // This is the exact format from Google Calendar's iCal export - now := time.Now().UTC().Truncate(time.Minute) +func TestClientFetchEvents_GoogleCalendarFormat(t *testing.T) { + now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC) start := now.Add(-time.Hour).Format("20060102T150405Z") end := now.Add(time.Hour).Format("20060102T150405Z") icalData := "BEGIN:VCALENDAR\n" + @@ -228,12 +234,9 @@ func TestFetchAndParseICalendar_GoogleCalendarFormat(t *testing.T) { })) defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - events, err := FetchAndParseICalendar(ctx, server.URL) + events, err := fetchEvents(t, server.URL, now) if err != nil { - t.Fatalf("FetchAndParseICalendar: %v", err) + t.Fatalf("FetchEvents: %v", err) } if len(events) != 1 { diff --git a/internal/config/config.go b/internal/config/config.go index 3e02f31..57d29cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,8 @@ import ( "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" + + "github.com/gldraphael/status/internal/timeutil" ) // Config holds application configuration. @@ -150,7 +152,7 @@ func (a AvailabilityConfig) Validate() error { if a.APIKey == "" { return fmt.Errorf("availability.api_key is required when availability is enabled") } - if _, _, err := parseClockRange(a.WorkingHours.Start, a.WorkingHours.End); err != nil { + if _, _, err := timeutil.ParseClockRange(a.WorkingHours.Start, a.WorkingHours.End); err != nil { return fmt.Errorf("availability.working_hours: %w", err) } if len(a.Blocks) == 0 { @@ -166,28 +168,13 @@ func (a AvailabilityConfig) Validate() error { if block.End == "" { return fmt.Errorf("availability.blocks[%d].end is required", i) } - if _, _, err := parseClockRange(block.Start, block.End); err != nil { + if _, _, err := timeutil.ParseClockRange(block.Start, block.End); err != nil { return fmt.Errorf("availability.blocks[%d]: %w", i, err) } } return nil } -func parseClockRange(startValue, endValue string) (time.Time, time.Time, error) { - start, err := time.Parse("15:04", strings.TrimSpace(startValue)) - if err != nil { - return time.Time{}, time.Time{}, err - } - end, err := time.Parse("15:04", strings.TrimSpace(endValue)) - if err != nil { - return time.Time{}, time.Time{}, err - } - if !end.After(start) { - return time.Time{}, time.Time{}, fmt.Errorf("end must be after start") - } - return start, end, nil -} - // Validate checks the configured build settings. func (b BuildConfig) Validate() error { if !b.IsEnabled { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dc336dd..f34553e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,13 +22,40 @@ func chdir(t *testing.T, dir string) { t.Cleanup(func() { os.Chdir(orig) }) } -func TestLoad_Defaults(t *testing.T) { - // Work in a temp dir with no config.yaml. - chdir(t, t.TempDir()) +var configEnvKeys = []string{ + "PORT", + "PEBBLE_PATH", + "CALENDAR_URL", + "GITHUB_TOKEN", + "AVAILABILITY_IS_ENABLED", + "AVAILABILITY_CALENDAR_URL", + "AVAILABILITY_API_KEY", + "AVAILABILITY_WORKING_HOURS_START", + "AVAILABILITY_WORKING_HOURS_END", + "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", + "BUILD_IS_ENABLED", + "BUILD_INTERVAL", + "BUILD_CF_DEPLOY_HOOK", +} - for _, key := range []string{"PORT", "PEBBLE_PATH", "CALENDAR_URL", "GITHUB_TOKEN", "AVAILABILITY_IS_ENABLED", "AVAILABILITY_CALENDAR_URL", "AVAILABILITY_API_KEY", "AVAILABILITY_WORKING_HOURS_START", "AVAILABILITY_WORKING_HOURS_END", "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "BUILD_IS_ENABLED", "BUILD_INTERVAL", "BUILD_CF_DEPLOY_HOOK"} { +func clearConfigEnv(t *testing.T) { + t.Helper() + for _, key := range configEnvKeys { t.Setenv(key, "") } +} + +func writeConfigYAML(t *testing.T, dir, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(body), 0600); err != nil { + t.Fatal(err) + } +} + +func TestLoad_Defaults(t *testing.T) { + // Work in a temp dir with no config.yaml. + chdir(t, t.TempDir()) + clearConfigEnv(t) cfg, err := Load() if err != nil { @@ -56,17 +83,12 @@ func TestLoad_Defaults(t *testing.T) { func TestLoad_FromEnv(t *testing.T) { chdir(t, t.TempDir()) + clearConfigEnv(t) t.Setenv("PORT", "9090") t.Setenv("PEBBLE_PATH", "/tmp/mydb") t.Setenv("CALENDAR_URL", "https://calendar.example.com/ical.ics") t.Setenv("GITHUB_TOKEN", "gh-abc123") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") cfg, err := Load() if err != nil { @@ -88,11 +110,8 @@ func TestLoad_FromEnv(t *testing.T) { func TestLoad_AvailabilityFromEnv(t *testing.T) { chdir(t, t.TempDir()) + clearConfigEnv(t) - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") t.Setenv("AVAILABILITY_IS_ENABLED", "true") t.Setenv("AVAILABILITY_CALENDAR_URL", "https://availability.example.com/ical.ics") t.Setenv("AVAILABILITY_API_KEY", "secret-key") @@ -123,16 +142,9 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { func TestLoad_InvalidPort(t *testing.T) { chdir(t, t.TempDir()) - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") + clearConfigEnv(t) + t.Setenv("PORT", "not-a-number") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") _, err := Load() if err == nil { t.Fatal("expected error for invalid PORT, got nil") @@ -142,18 +154,7 @@ func TestLoad_InvalidPort(t *testing.T) { func TestLoad_FromYAML(t *testing.T) { dir := t.TempDir() chdir(t, dir) - - // Clear env vars so they don't interfere with YAML loading - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") + clearConfigEnv(t) yaml := ` port: 7777 @@ -163,9 +164,7 @@ targets: github: token: gh-yaml ` - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + writeConfigYAML(t, dir, yaml) cfg, err := Load() if err != nil { @@ -188,17 +187,7 @@ targets: func TestLoad_AvailabilityFromYAML(t *testing.T) { dir := t.TempDir() chdir(t, dir) - - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") + clearConfigEnv(t) yaml := ` availability: @@ -217,9 +206,7 @@ availability: start: "17:30" end: "22:00" ` - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + writeConfigYAML(t, dir, yaml) cfg, err := Load() if err != nil { @@ -251,10 +238,8 @@ availability: func TestLoad_EnvOverridesYAML(t *testing.T) { dir := t.TempDir() chdir(t, dir) + clearConfigEnv(t) - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") yaml := ` port: 7777 calendar_url: https://yaml-cal.example.com/ical.ics @@ -267,20 +252,12 @@ targets: github: token: gh-yaml ` - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + writeConfigYAML(t, dir, yaml) // Env var must win over yaml. t.Setenv("GITHUB_TOKEN", "gh-env") t.Setenv("PORT", "9999") t.Setenv("CALENDAR_URL", "https://env-cal.example.com/ical.ics") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") cfg, err := Load() if err != nil { @@ -300,17 +277,7 @@ targets: func TestLoad_AvailabilityEnvOverridesYAML(t *testing.T) { dir := t.TempDir() chdir(t, dir) - - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") + clearConfigEnv(t) yaml := ` availability: @@ -326,9 +293,7 @@ availability: start: "09:00" end: "12:00" ` - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + writeConfigYAML(t, dir, yaml) t.Setenv("AVAILABILITY_IS_ENABLED", "true") t.Setenv("AVAILABILITY_CALENDAR_URL", "https://env-availability.example.com/ical.ics") @@ -361,26 +326,13 @@ availability: func TestLoad_YAMLOverridesDefaults(t *testing.T) { dir := t.TempDir() chdir(t, dir) - - // Clear env vars so they don't interfere with YAML loading - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") + clearConfigEnv(t) yaml := ` port: 3000 calendar_url: https://cal.example.com/ical.ics ` - if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0600); err != nil { - t.Fatal(err) - } + writeConfigYAML(t, dir, yaml) cfg, err := Load() if err != nil { @@ -401,16 +353,8 @@ calendar_url: https://cal.example.com/ical.ics func TestLoad_MissingYAML(t *testing.T) { // No config.yaml in the temp dir — should load without error. chdir(t, t.TempDir()) - t.Setenv("PORT", "") - t.Setenv("PEBBLE_PATH", "") - t.Setenv("CALENDAR_URL", "") - t.Setenv("GITHUB_TOKEN", "") - t.Setenv("AVAILABILITY_IS_ENABLED", "") - t.Setenv("AVAILABILITY_CALENDAR_URL", "") - t.Setenv("AVAILABILITY_API_KEY", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "") + clearConfigEnv(t) + cfg, err := Load() if err != nil { t.Fatalf("Load without config.yaml: %v", err) diff --git a/internal/deploy/syncer.go b/internal/deploy/syncer.go index 9f9e061..49d46c9 100644 --- a/internal/deploy/syncer.go +++ b/internal/deploy/syncer.go @@ -17,14 +17,15 @@ type Client interface { // Deployer periodically triggers builds using the provided Client. type Deployer struct { - client Client - store *store.Store - logger zerolog.Logger + client Client + store *store.Store + logger zerolog.Logger + nowFunc func() time.Time } // NewDeployer constructs a Deployer. func NewDeployer(client Client, st *store.Store, logger zerolog.Logger) *Deployer { - return &Deployer{client: client, store: st, logger: logger} + return &Deployer{client: client, store: st, logger: logger, nowFunc: time.Now} } // Run starts the deploy loop. It schedules the first deploy at the next time @@ -35,7 +36,7 @@ func (d *Deployer) Run(ctx context.Context, interval time.Duration) error { return fmt.Errorf("interval must be >= 1m") } - now := time.Now() + now := d.nowFunc() first := nextAlignedTime(now, interval, 1) // Wait until the first scheduled time. if wait := time.Until(first); wait > 0 { @@ -59,7 +60,7 @@ func (d *Deployer) Run(ctx context.Context, interval time.Duration) error { case <-ctx.Done(): return nil case <-ticker.C: - d.triggerIfDirty(ctx, time.Now()) + d.triggerIfDirty(ctx, d.nowFunc()) } } } diff --git a/internal/feed/feed.go b/internal/feed/feed.go index d79978e..1008d04 100644 --- a/internal/feed/feed.go +++ b/internal/feed/feed.go @@ -8,15 +8,31 @@ import ( "time" ) -// FetchBody fetches a feed body with the provided timeout. -func FetchBody(ctx context.Context, url string, timeout time.Duration) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +// Client fetches raw HTTP feed bodies. +type Client struct { + url string + httpClient *http.Client +} + +// NewClient creates a feed client with the provided timeout. +func NewClient(url string, timeout time.Duration) (*Client, error) { + if url == "" { + return nil, fmt.Errorf("feed URL is required") + } + return &Client{ + url: url, + httpClient: &http.Client{Timeout: timeout}, + }, nil +} + +// Fetch fetches the configured feed body. +func (c *Client) Fetch(ctx context.Context) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } - client := &http.Client{Timeout: timeout} - resp, err := client.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("fetch feed: %w", err) } diff --git a/internal/github/target.go b/internal/github/target.go index 87dda29..e073427 100644 --- a/internal/github/target.go +++ b/internal/github/target.go @@ -97,8 +97,18 @@ type graphQLResponse struct { } type graphQLPayload struct { - Query string `json:"query"` - Variables map[string]any `json:"variables"` + Query string `json:"query"` + Variables graphQLVariables `json:"variables"` +} + +type graphQLVariables struct { + Input changeUserStatusInput `json:"input"` +} + +type changeUserStatusInput struct { + Message string `json:"message"` + Emoji string `json:"emoji"` + ExpiresAt string `json:"expiresAt,omitempty"` } const changeUserStatusMutation = `mutation ChangeUserStatus($input: ChangeUserStatusInput!) { changeUserStatus(input: $input) { status { message emoji expiresAt } } }` @@ -106,22 +116,17 @@ const changeUserStatusMutation = `mutation ChangeUserStatus($input: ChangeUserSt // buildGraphQLPayload constructs the GraphQL request payload. Nil status clears // the profile status by sending empty message and emoji values. func buildGraphQLPayload(st *target.Status) graphQLPayload { - input := map[string]string{ - "message": "", - "emoji": "", - } + input := changeUserStatusInput{} if st != nil { - input["message"] = st.Text - input["emoji"] = st.Emoji + input.Message = st.Text + input.Emoji = st.Emoji if !st.Expiration.IsZero() { - input["expiresAt"] = st.Expiration.UTC().Format(time.RFC3339) + input.ExpiresAt = st.Expiration.UTC().Format(time.RFC3339) } } return graphQLPayload{ - Query: changeUserStatusMutation, - Variables: map[string]any{ - "input": input, - }, + Query: changeUserStatusMutation, + Variables: graphQLVariables{Input: input}, } } diff --git a/internal/github/target_test.go b/internal/github/target_test.go index c839062..0df48a6 100644 --- a/internal/github/target_test.go +++ b/internal/github/target_test.go @@ -24,15 +24,15 @@ func TestBuildGraphQLPayload_WithStatus(t *testing.T) { if !strings.Contains(payload.Query, `changeUserStatus`) { t.Errorf("mutation missing changeUserStatus") } - input := payload.Variables["input"].(map[string]string) - if input["message"] != "Shipping a new feature" { - t.Errorf("message: got %q", input["message"]) + input := payload.Variables.Input + if input.Message != "Shipping a new feature" { + t.Errorf("message: got %q", input.Message) } - if input["emoji"] != ":rocket:" { - t.Errorf("emoji: got %q", input["emoji"]) + if input.Emoji != ":rocket:" { + t.Errorf("emoji: got %q", input.Emoji) } - if input["expiresAt"] != "2026-04-07T00:00:00Z" { - t.Errorf("expiresAt: got %q", input["expiresAt"]) + if input.ExpiresAt != "2026-04-07T00:00:00Z" { + t.Errorf("expiresAt: got %q", input.ExpiresAt) } } @@ -43,25 +43,25 @@ func TestBuildGraphQLPayload_NoExpiry(t *testing.T) { } payload := buildGraphQLPayload(st) - input := payload.Variables["input"].(map[string]string) + input := payload.Variables.Input - if _, ok := input["expiresAt"]; ok { + if input.ExpiresAt != "" { t.Errorf("input should not have expiresAt: %+v", input) } - if input["message"] != "In a meeting" { - t.Errorf("message: got %q", input["message"]) + if input.Message != "In a meeting" { + t.Errorf("message: got %q", input.Message) } } func TestBuildGraphQLPayload_ClearsStatus(t *testing.T) { payload := buildGraphQLPayload(nil) - input := payload.Variables["input"].(map[string]string) + input := payload.Variables.Input - if input["message"] != "" { - t.Errorf("message: got %q, want empty", input["message"]) + if input.Message != "" { + t.Errorf("message: got %q, want empty", input.Message) } - if input["emoji"] != "" { - t.Errorf("emoji: got %q, want empty", input["emoji"]) + if input.Emoji != "" { + t.Errorf("emoji: got %q, want empty", input.Emoji) } } @@ -78,12 +78,12 @@ func TestGraphQLPayload_IsValidJSON(t *testing.T) { t.Fatalf("marshal to json: %v", err) } - var decoded map[string]any + var decoded graphQLPayload if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("unmarshal from json: %v", err) } - if _, ok := decoded["query"]; !ok { + if decoded.Query == "" { t.Errorf("decoded json missing query field") } } diff --git a/internal/store/store.go b/internal/store/store.go index 1f36bf7..f48bbe7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,7 +9,7 @@ import ( "github.com/cockroachdb/pebble" ) -// Status is the Slack status derived from active calendar events. +// Status is the target status derived from active calendar events. type Status struct { Emoji string `json:"emoji"` Text string `json:"text"` diff --git a/internal/timeutil/timeutil.go b/internal/timeutil/timeutil.go new file mode 100644 index 0000000..f7683ad --- /dev/null +++ b/internal/timeutil/timeutil.go @@ -0,0 +1,51 @@ +package timeutil + +import ( + "fmt" + "strings" + "time" +) + +const clockLayout = "15:04" + +// ParseClock parses an HH:MM value into a duration since midnight. +func ParseClock(value string) (time.Duration, error) { + t, err := time.Parse(clockLayout, strings.TrimSpace(value)) + if err != nil { + return 0, err + } + return time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute, nil +} + +// ParseClockRange parses and validates an ordered HH:MM range. +func ParseClockRange(startValue, endValue string) (time.Duration, time.Duration, error) { + start, err := ParseClock(startValue) + if err != nil { + return 0, 0, err + } + end, err := ParseClock(endValue) + if err != nil { + return 0, 0, err + } + if end <= start { + return 0, 0, fmt.Errorf("end must be after start") + } + return start, end, nil +} + +// LoadLocation returns the named location, falling back to UTC for empty or invalid names. +func LoadLocation(name string) *time.Location { + if name == "" { + return time.UTC + } + loc, err := time.LoadLocation(name) + if err != nil { + return time.UTC + } + return loc +} + +// Overlaps reports whether [start, end) intersects [windowStart, windowEnd). +func Overlaps(start, end, windowStart, windowEnd time.Time) bool { + return start.Before(windowEnd) && end.After(windowStart) +} diff --git a/internal/timeutil/timeutil_test.go b/internal/timeutil/timeutil_test.go new file mode 100644 index 0000000..c4fc927 --- /dev/null +++ b/internal/timeutil/timeutil_test.go @@ -0,0 +1,50 @@ +package timeutil + +import ( + "testing" + "time" +) + +func TestParseClockRange(t *testing.T) { + start, end, err := ParseClockRange("09:00", "17:50") + if err != nil { + t.Fatalf("ParseClockRange: %v", err) + } + if start != 9*time.Hour { + t.Fatalf("start: got %v, want 9h", start) + } + if end != 17*time.Hour+50*time.Minute { + t.Fatalf("end: got %v, want 17h50m", end) + } +} + +func TestParseClockRange_Invalid(t *testing.T) { + if _, _, err := ParseClockRange("17:00", "09:00"); err == nil { + t.Fatal("expected error for non-increasing range") + } + if _, _, err := ParseClockRange("bad", "09:00"); err == nil { + t.Fatal("expected error for invalid clock") + } +} + +func TestLoadLocationFallback(t *testing.T) { + if got := LoadLocation(""); got != time.UTC { + t.Fatalf("empty location: got %v, want UTC", got) + } + if got := LoadLocation("not-a-location"); got != time.UTC { + t.Fatalf("invalid location: got %v, want UTC", got) + } + if got := LoadLocation("Europe/London"); got.String() != "Europe/London" { + t.Fatalf("valid location: got %v", got) + } +} + +func TestOverlaps(t *testing.T) { + base := time.Date(2026, 4, 6, 9, 0, 0, 0, time.UTC) + if !Overlaps(base, base.Add(time.Hour), base.Add(30*time.Minute), base.Add(90*time.Minute)) { + t.Fatal("expected overlapping ranges") + } + if Overlaps(base, base.Add(time.Hour), base.Add(time.Hour), base.Add(2*time.Hour)) { + t.Fatal("expected touching ranges not to overlap") + } +} diff --git a/main.go b/main.go index 4d9d225..66d49f8 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/gldraphael/status/internal/calendar" "github.com/gldraphael/status/internal/config" deploy "github.com/gldraphael/status/internal/deploy" + "github.com/gldraphael/status/internal/feed" githubTarget "github.com/gldraphael/status/internal/github" "github.com/gldraphael/status/internal/server" "github.com/gldraphael/status/internal/store" @@ -103,17 +104,21 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st if err != nil { return nil, fmt.Errorf("parse availability working hours: %w", err) } - availabilityBlocks, err := availability.ParseBlocks(cfg.Blocks) + availabilityBlocks, err := parseAvailabilityBlocks(cfg.Blocks) if err != nil { return nil, fmt.Errorf("parse availability blocks: %w", err) } - availabilityClient, err := availability.NewClient(cfg.CalendarURL) + availabilityClient, err := feed.NewClient(cfg.CalendarURL, 30*time.Second) if err != nil { return nil, fmt.Errorf("create availability client: %w", err) } if cfg.ExcludeEnglandBankHolidays { - if err := availability.SyncHolidaySnapshot(ctx, st, availability.NewHolidayClient(), logger); err != nil { + holidayClient, err := availability.NewHolidayClient() + if err != nil { + return nil, fmt.Errorf("create bank holiday client: %w", err) + } + if err := availability.SyncHolidaySnapshot(ctx, st, holidayClient, logger); err != nil { return nil, fmt.Errorf("seed bank holidays: %w", err) } } @@ -123,6 +128,18 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st return availability.NewSyncer(st, provider, availabilityClient, logger), nil } +func parseAvailabilityBlocks(blocks []config.AvailabilityBlockConfig) ([]availability.Block, error) { + parsed := make([]availability.Block, 0, len(blocks)) + for i, block := range blocks { + parsedBlock, err := availability.ParseBlock(block.Name, block.Start, block.End) + if err != nil { + return nil, fmt.Errorf("availability.blocks[%d]: %w", i, err) + } + parsed = append(parsed, parsedBlock) + } + return parsed, nil +} + func startDeployLoop(ctx context.Context, cfg config.BuildConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { if !cfg.IsEnabled { return From 535d2d299c5f2dfa8ca0e5e8ef8d427a0d70f39c Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Thu, 14 May 2026 21:35:15 +0100 Subject: [PATCH 3/3] Bump version to v0.2.7 --- chart/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index f9f0677..1877c92 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart for Kubernetes type: application -version: 0.2.6 -appVersion: "v0.2.6" +version: 0.2.7 +appVersion: "v0.2.7"