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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
18 changes: 9 additions & 9 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 4 additions & 13 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }},
}
}

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ===
Expand Down
8 changes: 3 additions & 5 deletions internal/cli/status_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions internal/output/structured_time_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 10 additions & 4 deletions internal/output/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
6 changes: 4 additions & 2 deletions internal/output/table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"

flashduty "github.com/flashcatcloud/flashduty-sdk"
)

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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, "-")
}
Expand All @@ -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)
}
Expand Down
Loading