Skip to content

Commit dac3f9e

Browse files
committed
feat: adopt typed timestamps from flashduty-sdk
Bump flashduty-sdk to the Timestamp/TimestampMilli release: response time fields are now self-describing types that render RFC3339, so structured (JSON/TOON) output shows readable timestamps for free. - FormatTime takes a small local `instant` interface (satisfied by both flashduty.Timestamp and flashduty.TimestampMilli) instead of int64. - Remove formatWarRoomCreatedAt: its magnitude-based ms/sec guess is obsolete now that war-room created_at is a typed (seconds) Timestamp. - CreateIncident/CreateStatusIncident now return typed structs; read the typed IncidentID/ChangeID instead of map[string]any asserts.
1 parent 28098b2 commit dac3f9e

9 files changed

Lines changed: 91 additions & 38 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9
6+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260529032514-a227261bdb9a
77
github.com/mattn/go-runewidth v0.0.23
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.9

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9 h1:xNoqIR4zOHcX8TbLpn/ENaK/G6ZwpPyOeVTuqbE1uoc=
5-
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260528073358-9821a7ff07c9/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
4+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260529032514-a227261bdb9a h1:QdRXdFRI4rsUqmtMbMFwsB6vxQLLYyBhKPo9PU5uQ5Q=
5+
github.com/flashcatcloud/flashduty-sdk v0.9.1-0.20260529032514-a227261bdb9a/go.mod h1:dG4eJfdZaj4jNBMwEexbfK/3PmcIMhNeJ88L/DcZzUY=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=

internal/cli/command_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func (m *mockClient) ListSimilarIncidents(context.Context, string, int) (*flashd
4141
return nil, fmt.Errorf("mockClient: ListSimilarIncidents not implemented")
4242
}
4343

44-
func (m *mockClient) CreateIncident(context.Context, *flashduty.CreateIncidentInput) (any, error) {
44+
func (m *mockClient) CreateIncident(context.Context, *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) {
4545
return nil, fmt.Errorf("mockClient: CreateIncident not implemented")
4646
}
4747

@@ -149,7 +149,7 @@ func (m *mockClient) ListStatusChanges(context.Context, *flashduty.ListStatusCha
149149
return nil, fmt.Errorf("mockClient: ListStatusChanges not implemented")
150150
}
151151

152-
func (m *mockClient) CreateStatusIncident(context.Context, *flashduty.CreateStatusIncidentInput) (any, error) {
152+
func (m *mockClient) CreateStatusIncident(context.Context, *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) {
153153
return nil, fmt.Errorf("mockClient: CreateStatusIncident not implemented")
154154
}
155155

@@ -435,9 +435,9 @@ func TestCommandIncidentGetEmptyResults(t *testing.T) {
435435

436436
type mockCreateNoID struct{ mockClient }
437437

438-
func (m *mockCreateNoID) CreateIncident(_ context.Context, _ *flashduty.CreateIncidentInput) (any, error) {
439-
// Return a plain string instead of a map with "incident_id".
440-
return "ok", nil
438+
func (m *mockCreateNoID) CreateIncident(_ context.Context, _ *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error) {
439+
// Return an output with no incident_id to exercise the success fallback.
440+
return &flashduty.CreateIncidentOutput{}, nil
441441
}
442442

443443
func TestCommandIncidentCreateWithoutIncidentID(t *testing.T) {
@@ -506,8 +506,8 @@ func TestCommandIncidentTimelineEmpty(t *testing.T) {
506506

507507
type mockStatusCreateWithID struct{ mockClient }
508508

509-
func (m *mockStatusCreateWithID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (any, error) {
510-
return map[string]any{"change_id": float64(12345)}, nil
509+
func (m *mockStatusCreateWithID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) {
510+
return &flashduty.CreateStatusIncidentOutput{ChangeID: 12345}, nil
511511
}
512512

513513
func TestCommandStatusPageCreateIncidentWithChangeID(t *testing.T) {
@@ -549,8 +549,8 @@ func TestCommandStatusPageCreateIncidentWithChangeID_JSON(t *testing.T) {
549549

550550
type mockStatusCreateNoID struct{ mockClient }
551551

552-
func (m *mockStatusCreateNoID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (any, error) {
553-
return "ok", nil
552+
func (m *mockStatusCreateNoID) CreateStatusIncident(_ context.Context, _ *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error) {
553+
return &flashduty.CreateStatusIncidentOutput{}, nil
554554
}
555555

556556
func TestCommandStatusPageCreateIncidentWithoutChangeID(t *testing.T) {

internal/cli/incident.go

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,9 @@ func newIncidentCreateCmd() *cobra.Command {
223223
return err
224224
}
225225

226-
if m, ok := result.(map[string]any); ok {
227-
if id, ok := m["incident_id"]; ok {
228-
ctx.WriteResult(fmt.Sprintf("Incident created: %v", id))
229-
return nil
230-
}
226+
if result != nil && result.IncidentID != "" {
227+
ctx.WriteResult(fmt.Sprintf("Incident created: %s", result.IncidentID))
228+
return nil
231229
}
232230
ctx.WriteResult("Incident created successfully.")
233231
return nil
@@ -1104,7 +1102,7 @@ func incidentWarRoomColumns() []output.Column {
11041102
{Header: "INCIDENT_ID", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).IncidentID }},
11051103
{Header: "STATUS", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).Status }},
11061104
{Header: "PLUGIN", Field: func(v any) string { return v.(flashduty.IncidentWarRoomItem).PluginType }},
1107-
{Header: "CREATED", Field: func(v any) string { return formatWarRoomCreatedAt(v.(flashduty.IncidentWarRoomItem).CreatedAt) }},
1105+
{Header: "CREATED", Field: func(v any) string { return output.FormatTime(v.(flashduty.IncidentWarRoomItem).CreatedAt) }},
11081106
}
11091107
}
11101108

@@ -1126,13 +1124,6 @@ func printWarRoomDetail(w io.Writer, warRoom *flashduty.IncidentWarRoom) {
11261124
_, _ = fmt.Fprintf(w, "Share Link: %s\n", orDash(warRoom.ShareLink))
11271125
}
11281126

1129-
func formatWarRoomCreatedAt(ts int64) string {
1130-
if ts > 1_000_000_000_000 {
1131-
ts /= 1000
1132-
}
1133-
return output.FormatTime(ts)
1134-
}
1135-
11361127
func newIncidentFeedCmd() *cobra.Command {
11371128
var limit, page int
11381129

internal/cli/root.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type flashdutyClient interface {
2828
GetIncidentTimelines(ctx context.Context, incidentIDs []string) ([]flashduty.IncidentTimelineOutput, error)
2929
ListIncidentAlerts(ctx context.Context, incidentIDs []string, limit int) ([]flashduty.IncidentAlertsOutput, error)
3030
ListSimilarIncidents(ctx context.Context, incidentID string, limit int) (*flashduty.ListIncidentsOutput, error)
31-
CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (any, error)
31+
CreateIncident(ctx context.Context, input *flashduty.CreateIncidentInput) (*flashduty.CreateIncidentOutput, error)
3232
UpdateIncident(ctx context.Context, input *flashduty.UpdateIncidentInput) ([]string, error)
3333
AckIncidents(ctx context.Context, incidentIDs []string) error
3434
UnackIncidents(ctx context.Context, incidentIDs []string) error
@@ -55,7 +55,7 @@ type flashdutyClient interface {
5555
ValidateTemplate(ctx context.Context, input *flashduty.ValidateTemplateInput) (*flashduty.ValidateTemplateOutput, error)
5656
ListStatusPages(ctx context.Context, pageIDs []int64) ([]flashduty.StatusPage, error)
5757
ListStatusChanges(ctx context.Context, input *flashduty.ListStatusChangesInput) (*flashduty.ListStatusChangesOutput, error)
58-
CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (any, error)
58+
CreateStatusIncident(ctx context.Context, input *flashduty.CreateStatusIncidentInput) (*flashduty.CreateStatusIncidentOutput, error)
5959
CreateChangeTimeline(ctx context.Context, input *flashduty.CreateChangeTimelineInput) error
6060

6161
// === PHASE 1: Incident additions ===

internal/cli/status_page.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,9 @@ func newStatusPageCreateIncidentCmd() *cobra.Command {
127127
return err
128128
}
129129

130-
if m, ok := result.(map[string]any); ok {
131-
if id, ok := m["change_id"]; ok {
132-
ctx.WriteResult(fmt.Sprintf("Status incident created: %v", id))
133-
return nil
134-
}
130+
if result != nil && result.ChangeID != 0 {
131+
ctx.WriteResult(fmt.Sprintf("Status incident created: %d", result.ChangeID))
132+
return nil
135133
}
136134
ctx.WriteResult("Status incident created successfully.")
137135
return nil
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package output
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
flashduty "github.com/flashcatcloud/flashduty-sdk"
9+
)
10+
11+
// row carries a typed flashduty.Timestamp field so we can prove the structured
12+
// printers render it as RFC3339 rather than a raw epoch integer.
13+
type row struct {
14+
StartTime flashduty.Timestamp `json:"start_time" toon:"start_time"`
15+
}
16+
17+
// TestStructuredTimeIsRFC3339 is the regression guard for the typed-timestamp
18+
// SDK adoption: both the JSON and TOON printers must serialize a
19+
// flashduty.Timestamp as a human-/LLM-readable RFC3339 string, never as the
20+
// opaque Unix epoch integer.
21+
func TestStructuredTimeIsRFC3339(t *testing.T) {
22+
// 2026-05-28T08:00:00Z — fixed so we can assert the raw epoch is absent.
23+
const epochSec = 1779955200
24+
data := row{StartTime: flashduty.Timestamp(epochSec)}
25+
26+
cases := []struct {
27+
name string
28+
format Format
29+
}{
30+
{name: "JSON", format: FormatJSON},
31+
{name: "TOON", format: FormatTOON},
32+
}
33+
34+
for _, tc := range cases {
35+
t.Run(tc.name, func(t *testing.T) {
36+
var buf bytes.Buffer
37+
p := NewPrinter(tc.format, false, &buf)
38+
if err := p.Print(data, nil); err != nil {
39+
t.Fatalf("%s Print returned error: %v", tc.name, err)
40+
}
41+
out := buf.String()
42+
43+
// RFC3339 marker (date-time separator) and the year must be present.
44+
if !strings.Contains(out, "T") {
45+
t.Errorf("%s output missing RFC3339 'T' separator: %q", tc.name, out)
46+
}
47+
if !strings.Contains(out, "2026") {
48+
t.Errorf("%s output missing year 2026: %q", tc.name, out)
49+
}
50+
// The raw epoch integer must NOT appear — that's the bug we fixed.
51+
if strings.Contains(out, "1779955200") {
52+
t.Errorf("%s output leaked raw epoch integer: %q", tc.name, out)
53+
}
54+
})
55+
}
56+
}

internal/output/table.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,18 @@ func Truncate(s string, maxLen int) string {
9090
return runewidth.Truncate(s, maxLen, "...")
9191
}
9292

93-
// FormatTime formats a unix timestamp as local time.
94-
func FormatTime(ts int64) string {
95-
if ts == 0 {
93+
// instant is satisfied by flashduty.Timestamp and flashduty.TimestampMilli.
94+
type instant interface {
95+
Time() time.Time
96+
IsZero() bool
97+
}
98+
99+
// FormatTime formats an instant as local wall-clock time, or "-" when unset.
100+
func FormatTime(ts instant) string {
101+
if ts.IsZero() {
96102
return "-"
97103
}
98-
return time.Unix(ts, 0).Local().Format("2006-01-02 15:04")
104+
return ts.Time().Local().Format("2006-01-02 15:04")
99105
}
100106

101107
// FormatDuration formats seconds into human-readable duration (e.g., "2m 30s", "1h 15m").

internal/output/table_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"strings"
66
"testing"
77
"time"
8+
9+
flashduty "github.com/flashcatcloud/flashduty-sdk"
810
)
911

1012
// ---------------------------------------------------------------------------
@@ -53,7 +55,7 @@ func TestTruncate(t *testing.T) {
5355
func TestFormatTime(t *testing.T) {
5456
t.Run("zero returns dash", func(t *testing.T) {
5557
// 25
56-
got := FormatTime(0)
58+
got := FormatTime(flashduty.Timestamp(0))
5759
if got != "-" {
5860
t.Errorf("FormatTime(0) = %q, want %q", got, "-")
5961
}
@@ -63,7 +65,7 @@ func TestFormatTime(t *testing.T) {
6365
// 26
6466
const ts int64 = 1712000000
6567
want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04")
66-
got := FormatTime(ts)
68+
got := FormatTime(flashduty.Timestamp(ts))
6769
if got != want {
6870
t.Errorf("FormatTime(%d) = %q, want %q", ts, got, want)
6971
}

0 commit comments

Comments
 (0)