diff --git a/go.mod b/go.mod index 7b7294e..b962015 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.25.1 require ( - github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 + github.com/flashcatcloud/flashduty-sdk v0.9.1 github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 diff --git a/go.sum b/go.sum index a7b1070..fe5916d 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 h1:xNoqIR4zOHcX8TbLpn/ENaK/G6ZwpPyOeVTuqbE1uoc= -github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= +github.com/flashcatcloud/flashduty-sdk v0.9.1 h1:vDTkSjAJJD6Ex5r7S+VCxPi4yxSFNw1bU/SfoRCvk+k= +github.com/flashcatcloud/flashduty-sdk v0.9.1/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 7b5ac04..1ef73b6 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -41,7 +41,7 @@ func (m *mockClient) ListSimilarIncidents(context.Context, string, int) (*flashd return nil, fmt.Errorf("mockClient: ListSimilarIncidents not implemented") } -func (m *mockClient) CreateIncident(context.Context, *flashduty.CreateIncidentInput) (any, error) { +func (m *mockClient) CreateIncident(context.Context, *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) { return nil, fmt.Errorf("mockClient: CreateIncident not implemented") } @@ -149,7 +149,7 @@ func (m *mockClient) ListStatusChanges(context.Context, *flashduty.ListStatusCha return nil, fmt.Errorf("mockClient: ListStatusChanges not implemented") } -func (m *mockClient) CreateStatusIncident(context.Context, *flashduty.CreateStatusIncidentInput) (any, error) { +func (m *mockClient) CreateStatusIncident(context.Context, *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { return nil, fmt.Errorf("mockClient: CreateStatusIncident not implemented") } @@ -435,9 +435,9 @@ func TestCommandIncidentGetEmptyResults(t *testing.T) { type mockCreateNoID struct{ mockClient } -func (m *mockCreateNoID) CreateIncident(_ context.Context, _ *flashduty.CreateIncidentInput) (any, error) { - // Return a plain string instead of a map with "incident_id". - return "ok", nil +func (m *mockCreateNoID) CreateIncident(_ context.Context, _ *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) { + // Return an output with no incident_id to exercise the success fallback. + return &flashduty.CreateIncidentOutput{}, nil } func TestCommandIncidentCreateWithoutIncidentID(t *testing.T) { @@ -506,8 +506,8 @@ func TestCommandIncidentTimelineEmpty(t *testing.T) { type mockStatusCreateWithID struct{ mockClient } -func (m *mockStatusCreateWithID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (any, error) { - return map[string]any{"change_id": float64(12345)}, nil +func (m *mockStatusCreateWithID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { + return &flashduty.CreateStatusIncidentOutput{ChangeID: 12345}, nil } func TestCommandStatusPageCreateIncidentWithChangeID(t *testing.T) { @@ -549,8 +549,8 @@ func TestCommandStatusPageCreateIncidentWithChangeID_JSON(t *testing.T) { type mockStatusCreateNoID struct{ mockClient } -func (m *mockStatusCreateNoID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (any, error) { - return "ok", nil +func (m *mockStatusCreateNoID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) { + return &flashduty.CreateStatusIncidentOutput{}, nil } func TestCommandStatusPageCreateIncidentWithoutChangeID(t *testing.T) { diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 1bfcab2..8a22838 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -223,11 +223,9 @@ func newIncidentCreateCmd() *cobra.Command { return err } - if m, ok := result.(map[string]any); ok { - if id, ok := m["incident_id"]; ok { - ctx.WriteResult(fmt.Sprintf("Incident created: %v", id)) - return nil - } + if result != nil && result.IncidentID != "" { + ctx.WriteResult(fmt.Sprintf("Incident created: %s", result.IncidentID)) + return nil } ctx.WriteResult("Incident created successfully.") return nil @@ -1104,7 +1102,7 @@ func incidentWarRoomColumns() []output.Column { {Header: "INCIDENT_ID", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).IncidentID }}, {Header: "STATUS", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).Status }}, {Header: "PLUGIN", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).PluginType }}, - {Header: "CREATED", Field: func(v any) string { return formatWarRoomCreatedAt(v.(flashduty.IncidentWarRoomItem).CreatedAt) }}, + {Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentWarRoomItem).CreatedAt) }}, } } @@ -1126,13 +1124,6 @@ func printWarRoomDetail(w io.Writer, warRoom *flashduty.IncidentWarRoom) { _, _ = fmt.Fprintf(w, "Share Link: %s\n", orDash(warRoom.ShareLink)) } -func formatWarRoomCreatedAt(ts int64) string { - if ts > 1_000_000_000_000 { - ts /= 1000 - } - return output.FormatTime(ts) -} - func newIncidentFeedCmd() *cobra.Command { var limit, page int diff --git a/internal/cli/root.go b/internal/cli/root.go index 33e7280..347c567 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ type flashdutyClient interface { GetIncidentTimelines(ctx context.Context, incidentIDs []string) ([]flashduty.IncidentTimelineOutput, error) ListIncidentAlerts(ctx context.Context, incidentIDs []string, limit int) ([]flashduty.IncidentAlertsOutput, error) ListSimilarIncidents(ctx context.Context, incidentID string, limit int) (*flashduty.ListIncidentsOutput, error) - CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (any, error) + CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) UpdateIncident(ctx context.Context, input *flashduty.UpdateIncidentInput) ([]string, error) AckIncidents(ctx context.Context, incidentIDs []string) error UnackIncidents(ctx context.Context, incidentIDs []string) error @@ -55,7 +55,7 @@ type flashdutyClient interface { ValidateTemplate(ctx context.Context, input *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error) ListStatusPages(ctx context.Context, pageIDs []int64) ([]flashduty.StatusPage, error) ListStatusChanges(ctx context.Context, input *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error) - CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (any, error) + CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) CreateChangeTimeline(ctx context.Context, input *flashduty.CreateChangeTimelineInput) error // === PHASE 1: Incident additions === diff --git a/internal/cli/status_page.go b/internal/cli/status_page.go index 5f61435..69a0dac 100644 --- a/internal/cli/status_page.go +++ b/internal/cli/status_page.go @@ -127,11 +127,9 @@ func newStatusPageCreateIncidentCmd() *cobra.Command { return err } - if m, ok := result.(map[string]any); ok { - if id, ok := m["change_id"]; ok { - ctx.WriteResult(fmt.Sprintf("Status incident created: %v", id)) - return nil - } + if result != nil && result.ChangeID != 0 { + ctx.WriteResult(fmt.Sprintf("Status incident created: %d", result.ChangeID)) + return nil } ctx.WriteResult("Status incident created successfully.") return nil diff --git a/internal/output/structured_time_test.go b/internal/output/structured_time_test.go new file mode 100644 index 0000000..6839b4b --- /dev/null +++ b/internal/output/structured_time_test.go @@ -0,0 +1,56 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + flashduty "github.com/flashcatcloud/flashduty-sdk" +) + +// row carries a typed flashduty.Timestamp field so we can prove the structured +// printers render it as RFC3339 rather than a raw epoch integer. +type row struct { + StartTime flashduty.Timestamp `json:"start_time" toon:"start_time"` +} + +// TestStructuredTimeIsRFC3339 is the regression guard for the typed-timestamp +// SDK adoption: both the JSON and TOON printers must serialize a +// flashduty.Timestamp as a human-/LLM-readable RFC3339 string, never as the +// opaque Unix epoch integer. +func TestStructuredTimeIsRFC3339(t *testing.T) { + // 2026-05-28T08:00:00Z — fixed so we can assert the raw epoch is absent. + const epochSec = 1779955200 + data := row{StartTime: flashduty.Timestamp(epochSec)} + + cases := []struct { + name string + format Format + }{ + {name: "JSON", format: FormatJSON}, + {name: "TOON", format: FormatTOON}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + p := NewPrinter(tc.format, false, &buf) + if err := p.Print(data, nil); err != nil { + t.Fatalf("%s Print returned error: %v", tc.name, err) + } + out := buf.String() + + // RFC3339 marker (date-time separator) and the year must be present. + if !strings.Contains(out, "T") { + t.Errorf("%s output missing RFC3339 'T' separator: %q", tc.name, out) + } + if !strings.Contains(out, "2026") { + t.Errorf("%s output missing year 2026: %q", tc.name, out) + } + // The raw epoch integer must NOT appear — that's the bug we fixed. + if strings.Contains(out, "1779955200") { + t.Errorf("%s output leaked raw epoch integer: %q", tc.name, out) + } + }) + } +} diff --git a/internal/output/table.go b/internal/output/table.go index 367da02..8261438 100644 --- a/internal/output/table.go +++ b/internal/output/table.go @@ -90,12 +90,18 @@ func Truncate(s string, maxLen int) string { return runewidth.Truncate(s, maxLen, "...") } -// FormatTime formats a unix timestamp as local time. -func FormatTime(ts int64) string { - if ts == 0 { +// instant is satisfied by flashduty.Timestamp and flashduty.TimestampMilli. +type instant interface { + Time() time.Time + IsZero() bool +} + +// FormatTime formats an instant as local wall-clock time, or "-" when unset. +func FormatTime(ts instant) string { + if ts.IsZero() { return "-" } - return time.Unix(ts, 0).Local().Format("2006-01-02 15:04") + return ts.Time().Local().Format("2006-01-02 15:04") } // FormatDuration formats seconds into human-readable duration (e.g., "2m 30s", "1h 15m"). diff --git a/internal/output/table_test.go b/internal/output/table_test.go index 352cb8d..b31d22e 100644 --- a/internal/output/table_test.go +++ b/internal/output/table_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + flashduty "github.com/flashcatcloud/flashduty-sdk" ) // --------------------------------------------------------------------------- @@ -53,7 +55,7 @@ func TestTruncate(t *testing.T) { func TestFormatTime(t *testing.T) { t.Run("zero returns dash", func(t *testing.T) { // 25 - got := FormatTime(0) + got := FormatTime(flashduty.Timestamp(0)) if got != "-" { t.Errorf("FormatTime(0) = %q, want %q", got, "-") } @@ -63,7 +65,7 @@ func TestFormatTime(t *testing.T) { // 26 const ts int64 = 1712000000 want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04") - got := FormatTime(ts) + got := FormatTime(flashduty.Timestamp(ts)) if got != want { t.Errorf("FormatTime(%d) = %q, want %q", ts, got, want) }