Skip to content

fix: render unset timestamps as null in --json output - #170

Merged
ysyneu merged 3 commits into
mainfrom
fix/alert-end-time-type
Aug 27, 2026
Merged

fix: render unset timestamps as null in --json output#170
ysyneu merged 3 commits into
mainfrom
fix/alert-end-time-type

Conversation

@ysyneu

Copy link
Copy Markdown
Contributor

Problem

In --json output, timestamp fields have an unstable type: a set value renders as an RFC3339 string, but an unset value renders as the bare integer 0. For alert list / alert get this means end_time is a string for recovered alerts and a number for active ones, so jq arithmetic over mixed-state results fails (string and number cannot be subtracted).

Root cause

The CLI passes typed go-flashduty structs straight to json.MarshalIndent. The SDK's Timestamp.MarshalJSON (go-flashduty timestamp.go) emits a quoted RFC3339 string for a set value and the bare integer 0 for the unset sentinel — an intentional SDK-side round-trip contract. The wire format itself is consistent (always integer Unix seconds), so this is an output-rendering concern, and output formatting is the CLI's layer.

Fix

New output.NullUnsetInstants: a reflection-based transform applied at the CLI's JSON marshaling boundary (JSONPrinter.Print and marshalStructured). It rebuilds only subtrees containing SDK timestamps (reflect.StructOf over identical field names/tags/order) and replaces each unset timestamp with nil, so --json now renders:

  • set timestamp → RFC3339 string (unchanged, custom MarshalJSON preserved)
  • unset timestamp → null (was: bare 0)
  • non-timestamp zero integers → still 0 (untouched)

Struct field order and byte shape are preserved when no unset instant is present (covered by a byte-identity test). TOON and table output are unchanged: both render the sentinel through String(), already always a string.

Docs that described the old contract are updated: the generated --help note (cligen timestampOverrideNote + regenerated zz_generated_*, note-phrase-only changes) and skills/flashduty/SKILL.md.

Verification

  • make check (fmt + golangci-lint + go test -race ./... + build) — all green.
  • New tests in internal/output/instant_test.go: zero→null, set→RFC3339, TimestampMilli, slices/pointers/projected map[string]any rows, non-timestamp 0 untouched, byte-identical field order.
  • End-to-end against a local stub API with one active and one recovered alert:
    • alert list --json"end_time": null / "2026-08-26T20:16:08-07:00"
    • alert get --json and --fields alert_id,end_time projection → same
    • table mode unchanged.

go-flashduty's Timestamp.MarshalJSON emits a quoted RFC3339 string for a
set value but the bare integer 0 for the unset sentinel, so one field
switches JSON type depending on record state: an active alert's end_time
is the number 0 while a recovered alert's is a string. jq arithmetic over
mixed-state output fails with "string and number cannot be subtracted".
Add output.NullUnsetInstants, a reflection-based transform applied at the
JSON marshaling boundary (JSONPrinter.Print and marshalStructured), that
replaces every unset SDK timestamp with nil so it renders as null. Set
values keep their concrete type and custom MarshalJSON, non-timestamp
zero integers are untouched, and struct field order is preserved
(verified byte-identical when no unset instant is present). TOON and
table output are unchanged: they render the sentinel through String(),
already always a string.
The generated help note and the skill card prose that documented the old
"unset stays the bare integer 0" contract are updated to match.
writeSessionList marshals SessionItem structs directly, bypassing both
patched boundaries (JSONPrinter.Print and marshalStructured), so
session list --output-format json/jsonl still emitted "archived_at": 0
for live (not archived) sessions while the generated help note already
claimed unset values render as null. Route both the json envelope and
the per-line jsonl encode through output.NullUnsetInstants, and add a
regression test covering unset archived_at -> null on both paths.
Also collapse the instantLike interface added in instant.go into the
output package's existing instant interface (table.go): the Unix method
was redundant since Time() already excludes time.Time.
@ysyneu
ysyneu changed the base branch from feat/ai-sre to mainAugust 27, 2026 01:55
@ysyneu
ysyneu merged commit 2e19d39 into mainAug 27, 2026
12 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ysyneu