Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
88 changes: 88 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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`): 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

- 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.
20 changes: 10 additions & 10 deletions account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 2 additions & 2 deletions changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
2 changes: 1 addition & 1 deletion enrichment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
36 changes: 18 additions & 18 deletions incident_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions incidents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
22 changes: 11 additions & 11 deletions member_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
96 changes: 96 additions & 0 deletions migration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 7 additions & 7 deletions reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading