From 07eed365a34c96432cdbed8e6d2fdf57d40c333d Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 29 May 2026 11:20:26 +0800 Subject: [PATCH 1/3] feat: render API timestamps as RFC3339 via self-describing types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Timestamp` (Unix seconds) and `TimestampMilli` (Unix milliseconds) types whose MarshalJSON emits RFC3339 in the local timezone, so structured output is human- and LLM-readable instead of opaque integers. The zero value marshals to 0 (an unset sentinel, never a 1970 date) and is dropped by `omitempty`; UnmarshalJSON accepts a numeric epoch or an RFC3339 string. TimestampMilli uses RFC3339Nano so sub-second precision survives a round-trip. Re-type every absolute-instant field in RESPONSE structs to these (59 seconds, 4 millis). Durations, cyclic-window offsets, counts, ids, and all request-input fields stay int64 — the unit/instant knowledge now lives on the field type, so consumers (CLI, MCP) get readable timestamps with no downstream guessing. Type the two previously-`any` create methods: CreateIncident -> *CreateIncidentOutput, CreateStatusIncident -> *CreateStatusIncidentOutput, retiring the last untyped return in the SDK. Add CLAUDE.md (+ AGENTS.md symlink) documenting the conventions. BREAKING (pre-1.0): response time fields change from int64 to Timestamp/TimestampMilli; the two create methods now return typed structs. --- AGENTS.md | 1 + CLAUDE.md | 86 ++++++++++++++ account.go | 20 ++-- audit.go | 2 +- changes.go | 4 +- enrichment.go | 2 +- incident_lifecycle.go | 36 +++--- incidents.go | 16 +-- member_info.go | 22 ++-- migration_test.go | 96 ++++++++++++++++ reports.go | 14 +-- schedules.go | 34 +++--- statuspage.go | 20 ++-- timestamp.go | 113 +++++++++++++++++++ timestamp_test.go | 256 ++++++++++++++++++++++++++++++++++++++++++ types.go | 178 ++++++++++++++--------------- 16 files changed, 728 insertions(+), 172 deletions(-) create mode 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 migration_test.go create mode 100644 timestamp.go create mode 100644 timestamp_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8613384 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +Guidance for AI coding agents (and humans) working in this repository. `AGENTS.md` +is a symlink to this file. + +## What this is + +`github.com/flashcatcloud/flashduty-sdk` is the official **typed Go client** for the +[Flashduty](https://flashcat.cloud) open API. It is a public, `go get`-able library +with external consumers; two first-party consumers are the Flashduty CLI and the +Flashduty MCP server. Treat every exported symbol as a public API surface. + +## Architecture + +- **Single root package `flashduty`.** No subpackages. Flat, discoverable. +- **One file per API domain** — `incidents.go`, `schedules.go`, `alerts.go`, + `statuspage.go`, `audit.go`, `reports.go`, `insight.go`, … Each holds that domain's + `Client` methods and its request/response structs. +- **`client.go`** — transport: `makeRequest`, and the generic `postData[T]` / `getData[T]` + helpers that decode the API envelope into a typed value. +- **`types.go`** — shared response types used across domains. +- **`errors.go`** — typed API errors. **`logger.go`**, **`client_options.go`** — cross-cutting. + +## Conventions (our standards — follow them exactly) + +### Timestamps +- **All absolute time fields in RESPONSE structs use `flashduty.Timestamp`** + (Unix **seconds**) or **`flashduty.TimestampMilli`** (Unix **milliseconds**), + matching the wire unit. Both marshal to an RFC3339 string and unmarshal from + epoch-or-RFC3339; pick the variant the API actually sends (most fields are + seconds; feed/audit endpoints are milliseconds). This keeps machine-readable + output human- and LLM-friendly without any downstream guessing. Do **not** add + bare `int64` "...At/...Time" fields to responses. +- **Durations, cyclic-window offsets, and counts stay `int64`** (e.g. a rotation + length, a notification lead-time). They are not instants. When a time-typed field + must stay `int64`, state its unit and meaning in a comment — silence is a bug. +- **Request/input struct time fields stay `int64`** (callers pass epochs; inputs are + never rendered for humans). Document the unit (`// Unix seconds`). +- If you genuinely cannot tell whether an `int64` is an instant or a duration, it + stays `int64` until the API contract proves otherwise. Never guess a field into a + timestamp — a wrong rendered date is worse than a raw integer. + +### API methods +- Every exported `Client` method returns a **typed struct/slice**, never `any` / + `interface{}` / `map[string]any`. If the API adds a new response, add the struct. +- Signature shape: `func (c *Client) Verb(ctx context.Context, in *VerbInput) (*VerbResult, error)`. +- Decode through `postData[T]` / `getData[T]`; don't hand-roll `json.Unmarshal` per method. + +## Engineering principles + +1. **First principles.** Reason from the API contract and the caller's real need, not + from analogy. Ask "what does this field/type *have* to be?" +2. **No over-engineering (YAGNI).** Build for the requirement that exists. No speculative + config knobs, no extension points without a concrete second caller. +3. **Root cause first.** Fix the cause, not the symptom. If a change touches many call + sites, route them through one shared helper rather than patching each. +4. **No transitional shims.** Land at the end state in one change — no bridging + feature flags, no dead-code paths, no "clean it up later." + +## Testing & verification + +- Gate (no Makefile/CI yet — run all four, all must be clean): + ``` + go test ./... && go vet ./... && go build ./... && gofmt -l . + ``` + `gofmt -l .` must print nothing. +- Unit tests are **table-driven** and **hermetic** — no live network. Use `httptest` + to stub the API; pin request shape and decode behavior. +- New behavior ships with a test that fails before the change and passes after. +- "Verified" means *I ran the gate and saw it pass.* Never infer success from a build + alone; never claim done on an unrun test. + +## Versioning & compatibility + +- Pre-1.0 (`v0.x`). Under Go module rules a `v0.x` bump carries no compat guarantee, but + this is a published SDK: treat any exported-type or signature change as **breaking** and + call it out explicitly in the PR description. Breaking changes bump the **minor** (`v0.(x+1).0`). +- Consumers pin via pseudo-version (`go get github.com/flashcatcloud/flashduty-sdk@`). + +## Git workflow + +- Feature work lives on its own branch off `main`; deliver via PR. Never commit to `main`. +- Never `git push --force`. +- Commit messages stay clean — **no `Co-Authored-By` / "Generated with" trailers** for + any code agent. +- One feature per branch/PR so review stays scoped. diff --git a/account.go b/account.go index ee513ac..8f0d863 100644 --- a/account.go +++ b/account.go @@ -7,16 +7,16 @@ import ( // AccountInfo contains account details returned by the account info API. type AccountInfo struct { - AccountID uint64 `json:"account_id"` - AccountName string `json:"account_name"` - Domain string `json:"domain"` - Email string `json:"email"` - Phone string `json:"phone,omitempty"` - CountryCode string `json:"country_code,omitempty"` - Avatar string `json:"avatar,omitempty"` - Locale string `json:"locale,omitempty"` - TimeZone string `json:"time_zone,omitempty"` - CreatedAt int64 `json:"created_at"` + AccountID uint64 `json:"account_id"` + AccountName string `json:"account_name"` + Domain string `json:"domain"` + Email string `json:"email"` + Phone string `json:"phone,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Avatar string `json:"avatar,omitempty"` + Locale string `json:"locale,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + CreatedAt Timestamp `json:"created_at"` } // GetAccountInfo retrieves the account information for the authenticated app key. diff --git a/audit.go b/audit.go index 174cc5c..1485032 100644 --- a/audit.go +++ b/audit.go @@ -34,7 +34,7 @@ type AuditLogParam struct { // AuditLogRecord represents a single audit log entry returned by /audit/search. type AuditLogRecord struct { - CreatedAt int64 `json:"created_at"` + CreatedAt TimestampMilli `json:"created_at"` AccountID int64 `json:"account_id"` MemberID int64 `json:"member_id"` MemberName string `json:"member_name"` diff --git a/changes.go b/changes.go index 4302c2c..80207bb 100644 --- a/changes.go +++ b/changes.go @@ -124,8 +124,8 @@ func (c *Client) ListChanges(ctx context.Context, input *ListChangesInput) (*Lis Status: item.Status, ChannelID: item.ChannelID, CreatorID: item.CreatorID, - StartTime: item.StartTime, - EndTime: item.EndTime, + StartTime: Timestamp(item.StartTime), + EndTime: Timestamp(item.EndTime), Labels: item.Labels, } diff --git a/enrichment.go b/enrichment.go index fb1adc3..c279e53 100644 --- a/enrichment.go +++ b/enrichment.go @@ -60,7 +60,7 @@ func (c *Client) fetchIncidentAlerts(ctx context.Context, incidentID string, lim Title: item.Title, Severity: item.Severity, Status: item.Status, - StartTime: item.TriggerTime, + StartTime: Timestamp(item.TriggerTime), Labels: item.Labels, }) } diff --git a/incident_lifecycle.go b/incident_lifecycle.go index 31c78b4..8634b09 100644 --- a/incident_lifecycle.go +++ b/incident_lifecycle.go @@ -62,16 +62,16 @@ type IncidentWarRoomAddMemberInput struct { // IncidentWarRoom represents an IM war room. type IncidentWarRoom struct { - AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` - IntegrationID int64 `json:"integration_id,omitempty" toon:"integration_id,omitempty"` - ChatID string `json:"chat_id" toon:"chat_id"` - ChatName string `json:"chat_name,omitempty" toon:"chat_name,omitempty"` - ShareLink string `json:"share_link,omitempty" toon:"share_link,omitempty"` - IncidentID string `json:"incident_id,omitempty" toon:"incident_id,omitempty"` - CreatedBy int64 `json:"created_by,omitempty" toon:"created_by,omitempty"` - CreatedAt int64 `json:"created_at,omitempty" toon:"created_at,omitempty"` - PluginType string `json:"plugin_type,omitempty" toon:"plugin_type,omitempty"` - Status string `json:"status,omitempty" toon:"status,omitempty"` + AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` + IntegrationID int64 `json:"integration_id,omitempty" toon:"integration_id,omitempty"` + ChatID string `json:"chat_id" toon:"chat_id"` + ChatName string `json:"chat_name,omitempty" toon:"chat_name,omitempty"` + ShareLink string `json:"share_link,omitempty" toon:"share_link,omitempty"` + IncidentID string `json:"incident_id,omitempty" toon:"incident_id,omitempty"` + CreatedBy int64 `json:"created_by,omitempty" toon:"created_by,omitempty"` + CreatedAt Timestamp `json:"created_at,omitempty" toon:"created_at,omitempty"` + PluginType string `json:"plugin_type,omitempty" toon:"plugin_type,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` } // IncidentWarRoomItem represents a war room list item. @@ -84,14 +84,14 @@ type IncidentWarRoomListOutput struct { // IncidentWarRoomObserver represents a default observer candidate for war room invitation. type IncidentWarRoomObserver struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name,omitempty" toon:"person_name,omitempty"` - Nickname string `json:"nickname,omitempty" toon:"nickname,omitempty"` - Name string `json:"name,omitempty" toon:"name,omitempty"` - Email string `json:"email,omitempty" toon:"email,omitempty"` - Phone string `json:"phone,omitempty" toon:"phone,omitempty"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name,omitempty" toon:"person_name,omitempty"` + Nickname string `json:"nickname,omitempty" toon:"nickname,omitempty"` + Name string `json:"name,omitempty" toon:"name,omitempty"` + Email string `json:"email,omitempty" toon:"email,omitempty"` + Phone string `json:"phone,omitempty" toon:"phone,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + AssignedAt Timestamp `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` } // DisplayName returns the best available human-readable observer name. diff --git a/incidents.go b/incidents.go index c45ebfe..3911105 100644 --- a/incidents.go +++ b/incidents.go @@ -270,8 +270,14 @@ type CreateIncidentInput struct { AssignedTo []int // Optional person IDs to assign as responders } +// CreateIncidentOutput is the data payload of POST /incident/create. +type CreateIncidentOutput struct { + IncidentID string `json:"incident_id"` // 24-hex MongoDB ObjectID of the new incident + Title string `json:"title"` // echoes the request title +} + // CreateIncident creates a new incident -func (c *Client) CreateIncident(ctx context.Context, input *CreateIncidentInput) (any, error) { +func (c *Client) CreateIncident(ctx context.Context, input *CreateIncidentInput) (*CreateIncidentOutput, error) { requestBody := map[string]any{ "title": input.Title, "incident_severity": input.Severity, @@ -289,15 +295,11 @@ func (c *Client) CreateIncident(ctx context.Context, input *CreateIncidentInput) } } - data, err := postData[any](c, ctx, "/incident/create", requestBody, "failed to create incident") + data, err := postData[CreateIncidentOutput](c, ctx, "/incident/create", requestBody, "failed to create incident") if err != nil { return nil, err } - if data == nil { - return nil, nil - } - - return *data, nil + return data, nil } // UpdateIncidentInput contains parameters for updating an incident diff --git a/member_info.go b/member_info.go index 50dfb0f..ae58297 100644 --- a/member_info.go +++ b/member_info.go @@ -6,17 +6,17 @@ import ( ) type MemberInfo struct { - AccountID uint64 `json:"account_id"` - AccountName string `json:"account_name"` - MemberID uint64 `json:"member_id"` - MemberName string `json:"member_name"` - Email string `json:"email"` - Phone string `json:"phone,omitempty"` - CountryCode string `json:"country_code,omitempty"` - Avatar string `json:"avatar,omitempty"` - Locale string `json:"locale,omitempty"` - TimeZone string `json:"time_zone,omitempty"` - CreatedAt int64 `json:"created_at"` + AccountID uint64 `json:"account_id"` + AccountName string `json:"account_name"` + MemberID uint64 `json:"member_id"` + MemberName string `json:"member_name"` + Email string `json:"email"` + Phone string `json:"phone,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Avatar string `json:"avatar,omitempty"` + Locale string `json:"locale,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + CreatedAt Timestamp `json:"created_at"` } func (c *Client) GetMemberInfo(ctx context.Context) (*MemberInfo, error) { diff --git a/migration_test.go b/migration_test.go new file mode 100644 index 0000000..59eb771 --- /dev/null +++ b/migration_test.go @@ -0,0 +1,96 @@ +package flashduty + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// These tests lock in the int64 -> Timestamp/TimestampMilli migration: they fail +// if a migrated instant field is reverted to a raw integer, or if a trap field +// (duration/offset) is wrongly converted to a timestamp. + +func TestMigration_IncidentDetail_SecondsRenderRFC3339(t *testing.T) { + withLocal(t, "UTC") + d := IncidentDetail{ + IncidentID: "abc", + StartTime: Timestamp(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix()), + // AckTime / CloseTime left zero (omitempty) -> must be dropped, not "1970" + } + b, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(b) + if !strings.Contains(s, `"start_time":"2026-05-28T08:00:00Z"`) { + t.Errorf("start_time not RFC3339: %s", s) + } + if strings.Contains(s, "ack_time") || strings.Contains(s, "close_time") { + t.Errorf("zero omitempty time fields should be dropped, not rendered: %s", s) + } +} + +func TestMigration_TimelineEvent_MillisRenderRFC3339(t *testing.T) { + withLocal(t, "UTC") + e := TimelineEvent{ + Type: "ack", + Timestamp: TimestampMilli(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).UnixMilli()), + } + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(b), `"timestamp":"2026-05-28T08:00:00Z"`) { + t.Errorf("ms timestamp not RFC3339: %s", b) + } +} + +func TestMigration_ScheduleLayer_TrapsStayNumeric_InstantsRender(t *testing.T) { + withLocal(t, "UTC") + layer := ScheduleLayer{ + RotationDuration: 86400, // duration -> MUST stay numeric + HandoffTime: 3600, // within-cycle offset -> numeric + RestrictEnd: 3600, // cyclic-window offset -> numeric + EnableTime: Timestamp(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix()), + LayerStart: Timestamp(time.Date(2026, 5, 28, 9, 0, 0, 0, time.UTC).Unix()), + } + b, err := json.Marshal(layer) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(b) + for _, num := range []string{`"rotation_duration":86400`, `"handoff_time":3600`, `"restrict_end":3600`} { + if !strings.Contains(s, num) { + t.Errorf("trap field not left numeric (want %s): %s", num, s) + } + } + if !strings.Contains(s, `"enable_time":"2026-05-28T08:00:00Z"`) { + t.Errorf("enable_time not RFC3339: %s", s) + } + if !strings.Contains(s, `"layer_start":"2026-05-28T09:00:00Z"`) { + t.Errorf("layer_start not RFC3339: %s", s) + } +} + +func TestMigration_CreateIncidentOutput_Decode(t *testing.T) { + var out CreateIncidentOutput + body := `{"incident_id":"69db2ef1a0fe7db6448b14f1","title":"API test incident for docs"}` + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.IncidentID != "69db2ef1a0fe7db6448b14f1" || out.Title != "API test incident for docs" { + t.Errorf("got %+v", out) + } +} + +func TestMigration_CreateStatusIncidentOutput_Decode(t *testing.T) { + var out CreateStatusIncidentOutput + body := `{"change_id":6294539747131,"change_name":"API Test Incident"}` + if err := json.Unmarshal([]byte(body), &out); err != nil { + t.Fatalf("decode: %v", err) + } + if out.ChangeID != 6294539747131 || out.ChangeName != "API Test Incident" { + t.Errorf("got %+v", out) + } +} diff --git a/reports.go b/reports.go index 7d847ee..ef4453c 100644 --- a/reports.go +++ b/reports.go @@ -20,10 +20,10 @@ type QueryNotificationTrendOutput struct { // NotificationTrendPoint preserves the per-channel notification counters for each time bucket. type NotificationTrendPoint struct { - Timestamp int64 `json:"ts"` - SMSCount int `json:"sms_cnt"` - VoiceCount int `json:"voice_cnt"` - EmailCount int `json:"email_cnt"` + Timestamp Timestamp `json:"ts"` + SMSCount int `json:"sms_cnt"` + VoiceCount int `json:"voice_cnt"` + EmailCount int `json:"email_cnt"` } // QueryNotificationTrend queries notification volume trends over time @@ -73,9 +73,9 @@ type QueryChangeTrendOutput struct { // ChangeTrendPoint preserves the change counters for each time bucket. type ChangeTrendPoint struct { - Timestamp int64 `json:"ts"` - ChangeCount int `json:"change_cnt"` - ChangeEventCount int `json:"change_event_cnt"` + Timestamp Timestamp `json:"ts"` + ChangeCount int `json:"change_cnt"` + ChangeEventCount int `json:"change_event_cnt"` } // QueryChangeTrend queries change volume trends over time diff --git a/schedules.go b/schedules.go index fc9a071..83406e4 100644 --- a/schedules.go +++ b/schedules.go @@ -31,8 +31,8 @@ type ScheduleGroup struct { GroupName string `json:"group_name"` Name string `json:"name"` Members []ScheduleMember `json:"members"` - Start int64 `json:"start"` - End int64 `json:"end"` + Start Timestamp `json:"start"` + End Timestamp `json:"end"` } // ScheduleLayer represents a configured layer in a schedule. @@ -46,21 +46,21 @@ type ScheduleLayer struct { Groups []ScheduleGroup `json:"groups"` RotationDuration int64 `json:"rotation_duration"` HandoffTime int64 `json:"handoff_time"` - EnableTime int64 `json:"enable_time"` - ExpireTime int64 `json:"expire_time"` + EnableTime Timestamp `json:"enable_time"` + ExpireTime Timestamp `json:"expire_time"` RestrictMode int `json:"restrict_mode"` RestrictStart int64 `json:"restrict_start"` RestrictEnd int64 `json:"restrict_end"` RestrictPeriods []map[string]any `json:"restrict_periods,omitempty"` DayMask map[string]any `json:"day_mask,omitempty"` - CreateAt int64 `json:"create_at"` + CreateAt Timestamp `json:"create_at"` CreateBy int64 `json:"create_by"` - UpdateAt int64 `json:"update_at"` + UpdateAt Timestamp `json:"update_at"` UpdateBy int64 `json:"update_by"` LayerName string `json:"layer_name,omitempty"` FairRotation bool `json:"fair_rotation,omitempty"` - LayerStart int64 `json:"layer_start,omitempty"` - LayerEnd *int64 `json:"layer_end,omitempty"` + LayerStart Timestamp `json:"layer_start,omitempty"` + LayerEnd *Timestamp `json:"layer_end,omitempty"` RotationUnit string `json:"rotation_unit,omitempty"` RotationValue int64 `json:"rotation_value,omitempty"` MaskContinuousEnabled bool `json:"mask_continuous_enabled,omitempty"` @@ -68,8 +68,8 @@ type ScheduleLayer struct { // ScheduleCalculatedSchedule represents a computed slot inside a layer. type ScheduleCalculatedSchedule struct { - Start int64 `json:"start"` - End int64 `json:"end"` + Start Timestamp `json:"start"` + End Timestamp `json:"end"` Group ScheduleGroup `json:"group"` Index int `json:"index"` } @@ -101,10 +101,10 @@ type ScheduleNotify struct { // ScheduleOncallGroup represents the current or next on-call group snapshot. type ScheduleOncallGroup struct { - Start int64 `json:"start"` - End int64 `json:"end"` + Start Timestamp `json:"start"` + End Timestamp `json:"end"` Group ScheduleGroup `json:"group"` - UpdateAt int64 `json:"update_at"` + UpdateAt Timestamp `json:"update_at"` Weight int `json:"weight"` Index int `json:"index"` } @@ -116,16 +116,16 @@ type ScheduleDetail struct { AccountID int64 `json:"account_id"` GroupID *int64 `json:"group_id,omitempty"` Disabled *int `json:"disabled,omitempty"` - CreateAt int64 `json:"create_at"` + CreateAt Timestamp `json:"create_at"` CreateBy int64 `json:"create_by"` - UpdateAt int64 `json:"update_at"` + UpdateAt Timestamp `json:"update_at"` UpdateBy int64 `json:"update_by"` Layers []ScheduleLayer `json:"layers,omitempty"` Field string `json:"field,omitempty"` ScheduleLayers []ScheduleCalculatedLayer `json:"schedule_layers,omitempty"` FinalSchedule ScheduleCalculatedLayer `json:"final_schedule"` - Start int64 `json:"start,omitempty"` - End int64 `json:"end,omitempty"` + Start Timestamp `json:"start,omitempty"` + End Timestamp `json:"end,omitempty"` Notify ScheduleNotify `json:"notify,omitempty"` ScheduleID int64 `json:"schedule_id"` ScheduleName *string `json:"schedule_name,omitempty"` diff --git a/statuspage.go b/statuspage.go index 153dac6..fce43dd 100644 --- a/statuspage.go +++ b/statuspage.go @@ -125,8 +125,14 @@ type CreateStatusIncidentInput struct { NotifySubscribers bool // Whether to notify page subscribers } +// CreateStatusIncidentOutput is the data payload of POST /status-page/change/create. +type CreateStatusIncidentOutput struct { + ChangeID int64 `json:"change_id"` // ID of the newly created status-page change/incident + ChangeName string `json:"change_name"` // event title, echoed from the request +} + // CreateStatusIncident creates an incident on a status page -func (c *Client) CreateStatusIncident(ctx context.Context, input *CreateStatusIncidentInput) (any, error) { +func (c *Client) CreateStatusIncident(ctx context.Context, input *CreateStatusIncidentInput) (*CreateStatusIncidentOutput, error) { status := input.Status if status == "" { status = "investigating" @@ -178,15 +184,11 @@ func (c *Client) CreateStatusIncident(ctx context.Context, input *CreateStatusIn "notify_subscribers": input.NotifySubscribers, } - data, err := postData[any](c, ctx, "/status-page/change/create", requestBody, "failed to create status incident") + data, err := postData[CreateStatusIncidentOutput](c, ctx, "/status-page/change/create", requestBody, "failed to create status incident") if err != nil { return nil, err } - if data == nil { - return nil, nil - } - - return *data, nil + return data, nil } // CreateChangeTimelineInput contains parameters for adding a timeline entry @@ -272,8 +274,8 @@ type StatusPageMigrationJob struct { Status string `json:"status"` Progress StatusPageMigrationProgress `json:"progress"` Error string `json:"error,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + CreatedAt Timestamp `json:"created_at"` + UpdatedAt Timestamp `json:"updated_at"` } // StartStatusPageMigration starts an asynchronous migration of status page diff --git a/timestamp.go b/timestamp.go new file mode 100644 index 0000000..d921f53 --- /dev/null +++ b/timestamp.go @@ -0,0 +1,113 @@ +package flashduty + +import ( + "bytes" + "strconv" + "time" +) + +// Timestamp is a Unix-seconds instant as it appears on the Flashduty API wire. +// +// It marshals to an RFC3339 string in the local timezone, so structured output +// is human- and LLM-readable instead of an opaque integer. It unmarshals from +// either a numeric epoch (the wire form) or an RFC3339 string (so a marshaled +// value round-trips). The zero value marshals to 0 — an unset sentinel, never a +// 1970 date — and is dropped by `json:",omitempty"`. +// +// Use Timestamp only for absolute instants. Durations, cyclic-window offsets, +// and counts stay int64. +type Timestamp int64 + +// Time returns the instant as a time.Time. +func (t Timestamp) Time() time.Time { return time.Unix(int64(t), 0) } + +// Unix returns the raw wire value (Unix seconds). +func (t Timestamp) Unix() int64 { return int64(t) } + +// IsZero reports whether the value is the unset sentinel (0). +func (t Timestamp) IsZero() bool { return t == 0 } + +// MarshalJSON renders a non-zero value as a quoted RFC3339 string in the local +// timezone; zero renders as the bare integer 0. +func (t Timestamp) MarshalJSON() ([]byte, error) { + if t == 0 { + return []byte("0"), nil + } + return []byte(strconv.Quote(t.Time().In(time.Local).Format(time.RFC3339))), nil +} + +// UnmarshalJSON accepts a numeric Unix-seconds epoch, a quoted integer, an +// RFC3339 string, or null (→ 0). +func (t *Timestamp) UnmarshalJSON(b []byte) error { + n, err := parseEpochOrRFC3339(b, time.Second) + if err != nil { + return err + } + *t = Timestamp(n) + return nil +} + +// TimestampMilli is a Unix-milliseconds instant. It has the same rendering +// contract as Timestamp (RFC3339 out, epoch-or-RFC3339 in, zero→0); only the +// wire unit differs. +type TimestampMilli int64 + +// Time returns the instant as a time.Time. +func (t TimestampMilli) Time() time.Time { return time.UnixMilli(int64(t)) } + +// Unix returns the raw wire value (milliseconds since the Unix epoch). +func (t TimestampMilli) Unix() int64 { return int64(t) } + +// IsZero reports whether the value is the unset sentinel (0). +func (t TimestampMilli) IsZero() bool { return t == 0 } + +// MarshalJSON renders a non-zero value as a quoted RFC3339 string in the local +// timezone; zero renders as the bare integer 0. RFC3339Nano is used so that +// sub-second (millisecond) precision survives a marshal→unmarshal round-trip; +// it elides trailing zeros, so whole-second values render identically to a +// plain RFC3339 timestamp. +func (t TimestampMilli) MarshalJSON() ([]byte, error) { + if t == 0 { + return []byte("0"), nil + } + return []byte(strconv.Quote(t.Time().In(time.Local).Format(time.RFC3339Nano))), nil +} + +// UnmarshalJSON accepts a numeric Unix-milliseconds epoch, a quoted integer, an +// RFC3339 string, or null (→ 0). +func (t *TimestampMilli) UnmarshalJSON(b []byte) error { + n, err := parseEpochOrRFC3339(b, time.Millisecond) + if err != nil { + return err + } + *t = TimestampMilli(n) + return nil +} + +// parseEpochOrRFC3339 decodes a JSON token into a wire integer of the given unit +// (time.Second or time.Millisecond). Accepts null/empty → 0, a bare or quoted +// integer (returned as-is), or a quoted RFC3339 string (converted to the unit). +func parseEpochOrRFC3339(b []byte, unit time.Duration) (int64, error) { + s := string(bytes.TrimSpace(b)) + if s == "" || s == "null" { + return 0, nil + } + if s[0] == '"' { + inner := s[1 : len(s)-1] + if inner == "" { + return 0, nil + } + if n, err := strconv.ParseInt(inner, 10, 64); err == nil { + return n, nil + } + tm, err := time.Parse(time.RFC3339, inner) + if err != nil { + return 0, err + } + if unit == time.Millisecond { + return tm.UnixMilli(), nil + } + return tm.Unix(), nil + } + return strconv.ParseInt(s, 10, 64) +} diff --git a/timestamp_test.go b/timestamp_test.go new file mode 100644 index 0000000..f149b81 --- /dev/null +++ b/timestamp_test.go @@ -0,0 +1,256 @@ +package flashduty + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// withLocal temporarily pins time.Local so timezone-dependent rendering is +// deterministic, then restores it. +func withLocal(t *testing.T, name string) { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("load location %s: %v", name, err) + } + orig := time.Local + time.Local = loc + t.Cleanup(func() { time.Local = orig }) +} + +func TestTimestamp_MarshalJSON_RendersRFC3339InLocalTZ(t *testing.T) { + withLocal(t, "Asia/Shanghai") // UTC+8 + ts := Timestamp(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix()) + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // 08:00Z rendered in +08:00 is 16:00+08:00 + if want := `"2026-05-28T16:00:00+08:00"`; string(b) != want { + t.Errorf("MarshalJSON = %s, want %s", b, want) + } +} + +func TestTimestamp_MarshalJSON_ZeroStaysNumericZero(t *testing.T) { + b, err := json.Marshal(Timestamp(0)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != "0" { + t.Errorf("zero Timestamp = %s, want 0 (unset sentinel, never a 1970 date)", b) + } +} + +func TestTimestamp_UnmarshalJSON_FromEpochNumber(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte("1748419200"), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ts.Unix() != 1748419200 { + t.Errorf("Unix() = %d, want 1748419200", ts.Unix()) + } +} + +func TestTimestamp_UnmarshalJSON_FromRFC3339String(t *testing.T) { + want := time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix() + var ts Timestamp + if err := json.Unmarshal([]byte(`"2026-05-28T08:00:00Z"`), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ts.Unix() != want { + t.Errorf("Unix() = %d, want %d", ts.Unix(), want) + } +} + +func TestTimestamp_UnmarshalJSON_FromNull(t *testing.T) { + ts := Timestamp(123) + if err := json.Unmarshal([]byte("null"), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ts != 0 { + t.Errorf("null -> %d, want 0", ts) + } +} + +func TestTimestamp_RoundTrip(t *testing.T) { + withLocal(t, "Asia/Shanghai") + for _, epoch := range []int64{0, 1, 1748419200} { + ts := Timestamp(epoch) + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal %d: %v", epoch, err) + } + var back Timestamp + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + if back != ts { + t.Errorf("round-trip %d: got %d (via %s)", epoch, back, b) + } + } +} + +func TestTimestamp_OmitemptyDropsZero(t *testing.T) { + type wrap struct { + AckTime Timestamp `json:"ack_time,omitempty"` + } + b, err := json.Marshal(wrap{}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != "{}" { + t.Errorf("omitempty zero Timestamp not dropped: %s", b) + } +} + +func TestTimestamp_Helpers(t *testing.T) { + ts := Timestamp(1748419200) + if ts.Unix() != 1748419200 { + t.Errorf("Unix() = %d, want 1748419200", ts.Unix()) + } + if !ts.Time().Equal(time.Unix(1748419200, 0)) { + t.Errorf("Time() = %v, want %v", ts.Time(), time.Unix(1748419200, 0)) + } + if !Timestamp(0).IsZero() { + t.Errorf("Timestamp(0).IsZero() = false, want true") + } + if ts.IsZero() { + t.Errorf("non-zero IsZero() = true, want false") + } +} + +func TestTimestamp_InStructRendersRFC3339(t *testing.T) { + withLocal(t, "UTC") + type incident struct { + StartTime Timestamp `json:"start_time"` + } + b, err := json.Marshal(incident{StartTime: Timestamp(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix())}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := `{"start_time":"2026-05-28T08:00:00Z"}`; string(b) != want { + t.Errorf("struct marshal = %s, want %s", b, want) + } +} + +func TestTimestampMilli_MarshalJSON_RendersRFC3339InLocalTZ(t *testing.T) { + withLocal(t, "Asia/Shanghai") // UTC+8 + ts := TimestampMilli(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).UnixMilli()) + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := `"2026-05-28T16:00:00+08:00"`; string(b) != want { + t.Errorf("MarshalJSON = %s, want %s", b, want) + } +} + +func TestTimestampMilli_MarshalJSON_ZeroStaysNumericZero(t *testing.T) { + b, err := json.Marshal(TimestampMilli(0)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != "0" { + t.Errorf("zero TimestampMilli = %s, want 0", b) + } +} + +func TestTimestampMilli_UnmarshalJSON_FromEpochMillis(t *testing.T) { + var ts TimestampMilli + if err := json.Unmarshal([]byte("1779004800000"), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if int64(ts) != 1779004800000 { + t.Errorf("wire ms not preserved: got %d, want 1779004800000", int64(ts)) + } +} + +func TestTimestampMilli_UnmarshalJSON_FromRFC3339String(t *testing.T) { + want := time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).UnixMilli() + var ts TimestampMilli + if err := json.Unmarshal([]byte(`"2026-05-28T08:00:00Z"`), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if int64(ts) != want { + t.Errorf("ms = %d, want %d", int64(ts), want) + } +} + +func TestTimestampMilli_RoundTrip(t *testing.T) { + withLocal(t, "Asia/Shanghai") + for _, ms := range []int64{0, 1748419200000} { + ts := TimestampMilli(ms) + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal %d: %v", ms, err) + } + var back TimestampMilli + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + if back != ts { + t.Errorf("round-trip %d: got %d (via %s)", ms, back, b) + } + } +} + +func TestTimestampMilli_HelpersAndOmitempty(t *testing.T) { + ts := TimestampMilli(1748419200000) + if ts.Unix() != 1748419200000 { + t.Errorf("Unix() = %d, want 1748419200000 (raw ms value)", ts.Unix()) + } + if !ts.Time().Equal(time.UnixMilli(1748419200000)) { + t.Errorf("Time() = %v, want %v", ts.Time(), time.UnixMilli(1748419200000)) + } + if !TimestampMilli(0).IsZero() || ts.IsZero() { + t.Errorf("IsZero wrong") + } + type wrap struct { + CreatedAt TimestampMilli `json:"created_at,omitempty"` + } + b, _ := json.Marshal(wrap{}) + if string(b) != "{}" { + t.Errorf("omitempty zero TimestampMilli not dropped: %s", b) + } +} + +func TestTimestampMilli_RoundTrip_PreservesFractionalMillis(t *testing.T) { + withLocal(t, "UTC") + ts := TimestampMilli(1748419200123) // .123 of a second + b, err := json.Marshal(ts) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(b), ".123") { + t.Errorf("sub-second precision lost in marshal: %s", b) + } + var back TimestampMilli + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + if back != ts { + t.Errorf("fractional-ms round-trip lost data: got %d, want %d (via %s)", back, ts, b) + } +} + +func TestTimestamp_UnmarshalJSON_FromQuotedInteger(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(`"1748419200"`), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ts.Unix() != 1748419200 { + t.Errorf("quoted integer: got %d, want 1748419200", ts.Unix()) + } +} + +func TestTimestamp_UnmarshalJSON_FromEmptyString(t *testing.T) { + ts := Timestamp(99) + if err := json.Unmarshal([]byte(`""`), &ts); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ts != 0 { + t.Errorf(`empty string -> %d, want 0`, ts) + } +} diff --git a/types.go b/types.go index 987a925..3740f29 100644 --- a/types.go +++ b/types.go @@ -10,9 +10,9 @@ type EnrichedIncident struct { Progress string `json:"progress" toon:"progress"` // Time fields - StartTime int64 `json:"start_time" toon:"start_time"` - AckTime int64 `json:"ack_time,omitempty" toon:"ack_time,omitempty"` - CloseTime int64 `json:"close_time,omitempty" toon:"close_time,omitempty"` + StartTime Timestamp `json:"start_time" toon:"start_time"` + AckTime Timestamp `json:"ack_time,omitempty" toon:"ack_time,omitempty"` + CloseTime Timestamp `json:"close_time,omitempty" toon:"close_time,omitempty"` // Channel (enriched) ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` @@ -44,20 +44,20 @@ type EnrichedIncident struct { // EnrichedResponder contains responder info with human-readable names type EnrichedResponder struct { - PersonID int64 `json:"person_id" toon:"person_id"` - PersonName string `json:"person_name" toon:"person_name"` - Email string `json:"email,omitempty" toon:"email,omitempty"` - AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` - AcknowledgedAt int64 `json:"acknowledged_at,omitempty" toon:"acknowledged_at,omitempty"` + PersonID int64 `json:"person_id" toon:"person_id"` + PersonName string `json:"person_name" toon:"person_name"` + Email string `json:"email,omitempty" toon:"email,omitempty"` + AssignedAt Timestamp `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` + AcknowledgedAt Timestamp `json:"acknowledged_at,omitempty" toon:"acknowledged_at,omitempty"` } // TimelineEvent represents an entry in incident timeline type TimelineEvent struct { - Type string `json:"type" toon:"type"` - Timestamp int64 `json:"timestamp" toon:"timestamp"` - OperatorID int64 `json:"operator_id,omitempty" toon:"operator_id,omitempty"` - OperatorName string `json:"operator_name,omitempty" toon:"operator_name,omitempty"` - Detail any `json:"detail,omitempty" toon:"detail,omitempty"` + Type string `json:"type" toon:"type"` + Timestamp TimestampMilli `json:"timestamp" toon:"timestamp"` + OperatorID int64 `json:"operator_id,omitempty" toon:"operator_id,omitempty"` + OperatorName string `json:"operator_name,omitempty" toon:"operator_name,omitempty"` + Detail any `json:"detail,omitempty" toon:"detail,omitempty"` } // AlertPreview represents a preview of an alert @@ -66,7 +66,7 @@ type AlertPreview struct { Title string `json:"title" toon:"title"` Severity string `json:"severity" toon:"severity"` Status string `json:"status" toon:"status"` - StartTime int64 `json:"start_time" toon:"start_time"` + StartTime Timestamp `json:"start_time" toon:"start_time"` Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` } @@ -115,8 +115,8 @@ type TeamItem struct { CreatorName string `json:"creator_name" toon:"creator_name"` UpdatedBy int64 `json:"updated_by" toon:"updated_by"` UpdatedByName string `json:"updated_by_name" toon:"updated_by_name"` - CreatedAt int64 `json:"created_at" toon:"created_at"` - UpdatedAt int64 `json:"updated_at" toon:"updated_at"` + CreatedAt Timestamp `json:"created_at" toon:"created_at"` + UpdatedAt Timestamp `json:"updated_at" toon:"updated_at"` PersonIDs []int64 `json:"person_ids" toon:"person_ids"` RefID string `json:"ref_id" toon:"ref_id"` Members []TeamMember `json:"members,omitempty" toon:"members,omitempty"` @@ -297,17 +297,17 @@ type StatusChange struct { Description string `json:"description,omitempty" toon:"description,omitempty"` Type string `json:"type" toon:"type"` Status string `json:"status" toon:"status"` - CreatedAt int64 `json:"created_at" toon:"created_at"` - UpdatedAt int64 `json:"updated_at,omitempty" toon:"updated_at,omitempty"` + CreatedAt Timestamp `json:"created_at" toon:"created_at"` + UpdatedAt Timestamp `json:"updated_at,omitempty" toon:"updated_at,omitempty"` Timelines []ChangeTimeline `json:"timelines,omitempty" toon:"timelines,omitempty"` } // ChangeTimeline represents a timeline entry in status change type ChangeTimeline struct { - TimelineID int64 `json:"timeline_id" toon:"timeline_id"` - At int64 `json:"at" toon:"at"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - Description string `json:"description,omitempty" toon:"description,omitempty"` + TimelineID int64 `json:"timeline_id" toon:"timeline_id"` + At Timestamp `json:"at" toon:"at"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + Description string `json:"description,omitempty" toon:"description,omitempty"` } // Change represents a change record @@ -321,8 +321,8 @@ type Change struct { ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` CreatorName string `json:"creator_name,omitempty" toon:"creator_name,omitempty"` - StartTime int64 `json:"start_time,omitempty" toon:"start_time,omitempty"` - EndTime int64 `json:"end_time,omitempty" toon:"end_time,omitempty"` + StartTime Timestamp `json:"start_time,omitempty" toon:"start_time,omitempty"` + EndTime Timestamp `json:"end_time,omitempty" toon:"end_time,omitempty"` Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` } @@ -330,8 +330,8 @@ type Change struct { type RawTimelineItem struct { RefID string `json:"ref_id,omitempty"` Type string `json:"type"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at,omitempty"` + CreatedAt TimestampMilli `json:"created_at"` + UpdatedAt TimestampMilli `json:"updated_at,omitempty"` AccountID int64 `json:"account_id,omitempty"` CreatorID int64 `json:"creator_id,omitempty"` PersonID int64 `json:"person_id,omitempty"` @@ -345,9 +345,9 @@ type RawIncident struct { Description string `json:"description,omitempty"` Severity string `json:"incident_severity"` Progress string `json:"progress"` - StartTime int64 `json:"start_time"` - AckTime int64 `json:"ack_time,omitempty"` - CloseTime int64 `json:"close_time,omitempty"` + StartTime Timestamp `json:"start_time"` + AckTime Timestamp `json:"ack_time,omitempty"` + CloseTime Timestamp `json:"close_time,omitempty"` ChannelID int64 `json:"channel_id,omitempty"` CreatorID int64 `json:"creator_id,omitempty"` CloserID int64 `json:"closer_id,omitempty"` @@ -358,24 +358,24 @@ type RawIncident struct { // RawResponder represents raw responder data from API type RawResponder struct { - PersonID int64 `json:"person_id"` - AssignedAt int64 `json:"assigned_at,omitempty"` - AcknowledgedAt int64 `json:"acknowledged_at,omitempty"` - PersonName string `json:"person_name,omitempty"` - Email string `json:"email,omitempty"` - As string `json:"as,omitempty"` + PersonID int64 `json:"person_id"` + AssignedAt Timestamp `json:"assigned_at,omitempty"` + AcknowledgedAt Timestamp `json:"acknowledged_at,omitempty"` + PersonName string `json:"person_name,omitempty"` + Email string `json:"email,omitempty"` + As string `json:"as,omitempty"` } // AssignedTo represents the current assignment target for an incident. type AssignedTo struct { - PersonIDs []int64 `json:"person_ids,omitempty" toon:"person_ids,omitempty"` - EscalateRuleID string `json:"escalate_rule_id,omitempty" toon:"escalate_rule_id,omitempty"` - LayerIdx int `json:"layer_idx,omitempty" toon:"layer_idx,omitempty"` - Type string `json:"type,omitempty" toon:"type,omitempty"` - Emails []string `json:"emails,omitempty" toon:"emails,omitempty"` - EscalateRuleName string `json:"escalate_rule_name,omitempty" toon:"escalate_rule_name,omitempty"` - AssignedAt int64 `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` - ID string `json:"id,omitempty" toon:"id,omitempty"` + PersonIDs []int64 `json:"person_ids,omitempty" toon:"person_ids,omitempty"` + EscalateRuleID string `json:"escalate_rule_id,omitempty" toon:"escalate_rule_id,omitempty"` + LayerIdx int `json:"layer_idx,omitempty" toon:"layer_idx,omitempty"` + Type string `json:"type,omitempty" toon:"type,omitempty"` + Emails []string `json:"emails,omitempty" toon:"emails,omitempty"` + EscalateRuleName string `json:"escalate_rule_name,omitempty" toon:"escalate_rule_name,omitempty"` + AssignedAt Timestamp `json:"assigned_at,omitempty" toon:"assigned_at,omitempty"` + ID string `json:"id,omitempty" toon:"id,omitempty"` } // MemberListResponse represents the response for member list API @@ -391,19 +391,19 @@ type MemberListResponse struct { // MemberItem represents a member item as defined in the OpenAPI spec type MemberItem struct { - MemberID int `json:"member_id"` - MemberName string `json:"member_name"` - Phone string `json:"phone,omitempty"` - PhoneVerified bool `json:"phone_verified,omitempty"` - Email string `json:"email,omitempty"` - EmailVerified bool `json:"email_verified,omitempty"` - AccountRoleIDs []int `json:"account_role_ids,omitempty"` - TimeZone string `json:"time_zone,omitempty"` - Locale string `json:"locale,omitempty"` - Status string `json:"status"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - RefID string `json:"ref_id,omitempty"` + MemberID int `json:"member_id"` + MemberName string `json:"member_name"` + Phone string `json:"phone,omitempty"` + PhoneVerified bool `json:"phone_verified,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + AccountRoleIDs []int `json:"account_role_ids,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + Locale string `json:"locale,omitempty"` + Status string `json:"status"` + CreatedAt Timestamp `json:"created_at"` + UpdatedAt Timestamp `json:"updated_at"` + RefID string `json:"ref_id,omitempty"` } // MemberItemShort represents a short member item for invite response @@ -430,15 +430,15 @@ type TemplateFunction struct { // MetricsBase represents the shared bucket and dimension fields returned by /insight/* APIs. type MetricsBase struct { - Hours string `json:"hours,omitempty" toon:"hours,omitempty"` - TS int64 `json:"ts,omitempty" toon:"ts,omitempty"` - ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` - TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` - ResponderID int64 `json:"responder_id,omitempty" toon:"responder_id,omitempty"` - AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` - TeamName string `json:"team_name,omitempty" toon:"team_name,omitempty"` - ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` - ResponderName string `json:"responder_name,omitempty" toon:"responder_name,omitempty"` + Hours string `json:"hours,omitempty" toon:"hours,omitempty"` + TS Timestamp `json:"ts,omitempty" toon:"ts,omitempty"` + ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` + TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` + ResponderID int64 `json:"responder_id,omitempty" toon:"responder_id,omitempty"` + AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` + TeamName string `json:"team_name,omitempty" toon:"team_name,omitempty"` + ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` + ResponderName string `json:"responder_name,omitempty" toon:"responder_name,omitempty"` } // DimensionInsightItem represents pre-aggregated metrics for a team or channel dimension. @@ -506,7 +506,7 @@ type InsightIncidentItem struct { ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` Progress string `json:"progress" toon:"progress"` Severity string `json:"severity" toon:"severity"` - CreatedAt int64 `json:"created_at" toon:"created_at"` + CreatedAt Timestamp `json:"created_at" toon:"created_at"` ClosedBy string `json:"closed_by,omitempty" toon:"closed_by,omitempty"` SecondsToAck int `json:"seconds_to_ack" toon:"seconds_to_ack"` SecondsToClose int `json:"seconds_to_close" toon:"seconds_to_close"` @@ -535,9 +535,9 @@ type IncidentDetail struct { Description string `json:"description,omitempty" toon:"description,omitempty"` Severity string `json:"incident_severity" toon:"severity"` Progress string `json:"progress" toon:"progress"` - StartTime int64 `json:"start_time" toon:"start_time"` - AckTime int64 `json:"ack_time,omitempty" toon:"ack_time,omitempty"` - CloseTime int64 `json:"close_time,omitempty" toon:"close_time,omitempty"` + StartTime Timestamp `json:"start_time" toon:"start_time"` + AckTime Timestamp `json:"ack_time,omitempty" toon:"ack_time,omitempty"` + CloseTime Timestamp `json:"close_time,omitempty" toon:"close_time,omitempty"` ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` CreatorID int64 `json:"creator_id,omitempty" toon:"creator_id,omitempty"` @@ -557,20 +557,20 @@ type IncidentDetail struct { // PostMortem represents post-mortem metadata returned by /incident/post-mortem/list. type PostMortem struct { - AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` - PostMortemID string `json:"post_mortem_id" toon:"post_mortem_id"` - TemplateID string `json:"template_id,omitempty" toon:"template_id,omitempty"` - IncidentIDs []string `json:"incident_ids,omitempty" toon:"incident_ids,omitempty"` - MediaCount int `json:"media_count,omitempty" toon:"media_count,omitempty"` - AuthorIDs []int64 `json:"author_ids,omitempty" toon:"author_ids,omitempty"` - TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` - ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` - IsPrivate bool `json:"is_private,omitempty" toon:"is_private,omitempty"` - Title string `json:"title,omitempty" toon:"title,omitempty"` - Status string `json:"status,omitempty" toon:"status,omitempty"` - CreatedAtSeconds int64 `json:"created_at_seconds,omitempty" toon:"created_at_seconds,omitempty"` - UpdatedAtSeconds int64 `json:"updated_at_seconds,omitempty" toon:"updated_at_seconds,omitempty"` + AccountID int64 `json:"account_id,omitempty" toon:"account_id,omitempty"` + PostMortemID string `json:"post_mortem_id" toon:"post_mortem_id"` + TemplateID string `json:"template_id,omitempty" toon:"template_id,omitempty"` + IncidentIDs []string `json:"incident_ids,omitempty" toon:"incident_ids,omitempty"` + MediaCount int `json:"media_count,omitempty" toon:"media_count,omitempty"` + AuthorIDs []int64 `json:"author_ids,omitempty" toon:"author_ids,omitempty"` + TeamID int64 `json:"team_id,omitempty" toon:"team_id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty" toon:"channel_id,omitempty"` + ChannelName string `json:"channel_name,omitempty" toon:"channel_name,omitempty"` + IsPrivate bool `json:"is_private,omitempty" toon:"is_private,omitempty"` + Title string `json:"title,omitempty" toon:"title,omitempty"` + Status string `json:"status,omitempty" toon:"status,omitempty"` + CreatedAtSeconds Timestamp `json:"created_at_seconds,omitempty" toon:"created_at_seconds,omitempty"` + UpdatedAtSeconds Timestamp `json:"updated_at_seconds,omitempty" toon:"updated_at_seconds,omitempty"` } // AlertEvent represents a raw alert event @@ -585,10 +585,10 @@ type AlertEvent struct { Description string `json:"description,omitempty" toon:"description,omitempty"` EventSeverity string `json:"event_severity" toon:"event_severity"` EventStatus string `json:"event_status" toon:"event_status"` - EventTime int64 `json:"event_time" toon:"event_time"` + EventTime Timestamp `json:"event_time" toon:"event_time"` Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` - CreatedAt int64 `json:"created_at,omitempty" toon:"created_at,omitempty"` - UpdatedAt int64 `json:"updated_at,omitempty" toon:"updated_at,omitempty"` + CreatedAt Timestamp `json:"created_at,omitempty" toon:"created_at,omitempty"` + UpdatedAt Timestamp `json:"updated_at,omitempty" toon:"updated_at,omitempty"` } // Alert represents a deduplicated alert entity (Layer 1) @@ -604,9 +604,9 @@ type Alert struct { AlertKey string `json:"alert_key,omitempty" toon:"alert_key,omitempty"` AlertSeverity string `json:"alert_severity" toon:"alert_severity"` AlertStatus string `json:"alert_status" toon:"alert_status"` - StartTime int64 `json:"start_time" toon:"start_time"` - LastTime int64 `json:"last_time,omitempty" toon:"last_time,omitempty"` - EndTime int64 `json:"end_time,omitempty" toon:"end_time,omitempty"` + StartTime Timestamp `json:"start_time" toon:"start_time"` + LastTime Timestamp `json:"last_time,omitempty" toon:"last_time,omitempty"` + EndTime Timestamp `json:"end_time,omitempty" toon:"end_time,omitempty"` EventCnt int `json:"event_cnt,omitempty" toon:"event_cnt,omitempty"` EverMuted bool `json:"ever_muted,omitempty" toon:"ever_muted,omitempty"` Labels map[string]string `json:"labels,omitempty" toon:"labels,omitempty"` From a227261bdb9a34ac873306f53f05398bc1da24d2 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 29 May 2026 11:25:14 +0800 Subject: [PATCH 2/3] fix: render Timestamp/TimestampMilli as RFC3339 in TOON output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toon-go renders named scalar types via fmt.Stringer (its normalize() has no case for reflect.Int64 on a named type, so a bare Timestamp errors with "unsupported value of type"). Add String() to both types returning the same RFC3339 representation MarshalJSON uses, so the CLI/MCP TOON output path — a primary LLM format — renders readable timestamps instead of erroring. MarshalJSON now reuses String() for the non-zero case (JSON still goes through MarshalJSON, never Stringer, so JSON output is unchanged). --- timestamp.go | 23 +++++++++++++++++++++-- timestamp_toon_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 timestamp_toon_test.go diff --git a/timestamp.go b/timestamp.go index d921f53..6ace2e2 100644 --- a/timestamp.go +++ b/timestamp.go @@ -27,13 +27,22 @@ func (t Timestamp) Unix() int64 { return int64(t) } // IsZero reports whether the value is the unset sentinel (0). func (t Timestamp) IsZero() bool { return t == 0 } +// String renders the instant as RFC3339 in the local timezone, or "0" when +// unset. Non-JSON encoders (TOON, fmt) render the value through this method. +func (t Timestamp) String() string { + if t == 0 { + return "0" + } + return t.Time().In(time.Local).Format(time.RFC3339) +} + // MarshalJSON renders a non-zero value as a quoted RFC3339 string in the local // timezone; zero renders as the bare integer 0. func (t Timestamp) MarshalJSON() ([]byte, error) { if t == 0 { return []byte("0"), nil } - return []byte(strconv.Quote(t.Time().In(time.Local).Format(time.RFC3339))), nil + return []byte(strconv.Quote(t.String())), nil } // UnmarshalJSON accepts a numeric Unix-seconds epoch, a quoted integer, an @@ -61,6 +70,16 @@ func (t TimestampMilli) Unix() int64 { return int64(t) } // IsZero reports whether the value is the unset sentinel (0). func (t TimestampMilli) IsZero() bool { return t == 0 } +// String renders the instant as RFC3339Nano in the local timezone (preserving +// sub-second precision), or "0" when unset. Non-JSON encoders (TOON, fmt) +// render the value through this method. +func (t TimestampMilli) String() string { + if t == 0 { + return "0" + } + return t.Time().In(time.Local).Format(time.RFC3339Nano) +} + // MarshalJSON renders a non-zero value as a quoted RFC3339 string in the local // timezone; zero renders as the bare integer 0. RFC3339Nano is used so that // sub-second (millisecond) precision survives a marshal→unmarshal round-trip; @@ -70,7 +89,7 @@ func (t TimestampMilli) MarshalJSON() ([]byte, error) { if t == 0 { return []byte("0"), nil } - return []byte(strconv.Quote(t.Time().In(time.Local).Format(time.RFC3339Nano))), nil + return []byte(strconv.Quote(t.String())), nil } // UnmarshalJSON accepts a numeric Unix-milliseconds epoch, a quoted integer, an diff --git a/timestamp_toon_test.go b/timestamp_toon_test.go new file mode 100644 index 0000000..dbf8e5d --- /dev/null +++ b/timestamp_toon_test.go @@ -0,0 +1,36 @@ +package flashduty + +import ( + "strings" + "testing" + "time" +) + +// TOON is a primary LLM output format for the CLI/MCP. toon-go renders scalars +// via fmt.Stringer for named types, so Timestamp/TimestampMilli must produce +// RFC3339 there too — not error out or emit a raw integer. + +func TestTimestamp_TOON_RendersRFC3339(t *testing.T) { + withLocal(t, "UTC") + type row struct { + StartTime Timestamp `json:"start_time" toon:"start_time"` + Created TimestampMilli `json:"created" toon:"created"` + AckTime Timestamp `json:"ack_time,omitempty" toon:"ack_time,omitempty"` + } + r := row{ + StartTime: Timestamp(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).Unix()), + Created: TimestampMilli(time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC).UnixMilli()), + // AckTime zero + omitempty -> dropped + } + b, err := Marshal(r, OutputFormatTOON) + if err != nil { + t.Fatalf("toon marshal errored (Timestamp not TOON-renderable): %v", err) + } + s := string(b) + if strings.Count(s, "2026-05-28T08:00:00") < 2 { + t.Errorf("toon did not render both timestamps as RFC3339:\n%s", s) + } + if strings.Contains(s, "ack_time") { + t.Errorf("omitempty zero Timestamp should be dropped in TOON:\n%s", s) + } +} From 2819ab97fd9490b1bb3ec0837b2ab8e6fac562a8 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 29 May 2026 11:56:24 +0800 Subject: [PATCH 3/3] docs: pre-1.0 versioning is patch-minimal; breaking allowed if flagged in PR --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8613384..6d25358 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,10 +72,12 @@ Flashduty MCP server. Treat every exported symbol as a public API surface. ## Versioning & compatibility -- Pre-1.0 (`v0.x`). Under Go module rules a `v0.x` bump carries no compat guarantee, but - this is a published SDK: treat any exported-type or signature change as **breaking** and - call it out explicitly in the PR description. Breaking changes bump the **minor** (`v0.(x+1).0`). -- Consumers pin via pseudo-version (`go get github.com/flashcatcloud/flashduty-sdk@`). +- Pre-1.0 (`v0.x`): a `v0.x` bump carries no compat guarantee (Go module rules), so keep + version churn minimal — each release bumps the **patch** (`v0.x.(z+1)`). Breaking changes + (exported-type or signature changes) are permitted pre-1.0, but **MUST be called out + explicitly in the PR description** so consumers upgrade deliberately. Reserve a minor bump + for a deliberate milestone, not for every break. +- Consumers pin via tag or pseudo-version (`go get github.com/flashcatcloud/flashduty-sdk@v0.x.y`). ## Git workflow