Skip to content

Commit 3d6dafc

Browse files
authored
Merge pull request #75 from flashcatcloud/codex/release-2026-07-02-main
release: merge audit fixes for v1.3.22
2 parents 81d5149 + 8400d8b commit 3d6dafc

16 files changed

Lines changed: 294 additions & 75 deletions

internal/cli/fieldproject_test.go

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func incidentRow() map[string]any {
1717
"incident_severity": "Critical",
1818
"progress": "Triggered",
1919
"start_time": 1712000000,
20+
"channel_id": 12345,
2021
"description": "root volume at 98%",
2122
"labels": map[string]any{"service": "db", "env": "prod"},
2223
"responders": []map[string]any{
@@ -41,19 +42,84 @@ func alertRow() map[string]any {
4142
}
4243
}
4344

44-
// TestFieldsProjectionDefaultUnchanged is the conductor constraint: with NO
45-
// --fields, the structured (toon and json) output must still be the full nested
46-
// record — the nested blobs the proposal deliberately preserves as the default.
47-
func TestFieldsProjectionDefaultUnchanged(t *testing.T) {
45+
// TestIncidentListStructuredDefaultUsesCompactProjection is the default agent
46+
// path: incident list in json/toon mode must not dump the full nested SDK row
47+
// when --fields is omitted, while an explicit --fields still wins.
48+
func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
49+
t.Run("json default", func(t *testing.T) {
50+
saveAndResetGlobals(t)
51+
stub := newGFStub(t)
52+
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
53+
54+
out, err := execCommand("incident", "list", "--output-format", "json")
55+
if err != nil {
56+
t.Fatalf("execCommand: %v", err)
57+
}
58+
59+
assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"})
60+
})
61+
62+
t.Run("toon default", func(t *testing.T) {
63+
saveAndResetGlobals(t)
64+
stub := newGFStub(t)
65+
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
66+
67+
out, err := execCommand("incident", "list", "--output-format", "toon")
68+
if err != nil {
69+
t.Fatalf("execCommand: %v", err)
70+
}
71+
72+
for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} {
73+
if !strings.Contains(out, key) {
74+
t.Errorf("default toon output missing compact key %q, got:\n%s", key, out)
75+
}
76+
}
77+
for _, key := range []string{"responders", "labels", "description"} {
78+
if strings.Contains(out, key) {
79+
t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out)
80+
}
81+
}
82+
})
83+
84+
t.Run("explicit fields win", func(t *testing.T) {
85+
saveAndResetGlobals(t)
86+
stub := newGFStub(t)
87+
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
88+
89+
out, err := execCommand("incident", "list", "--fields", "incident_id,title", "--output-format", "json")
90+
if err != nil {
91+
t.Fatalf("execCommand: %v", err)
92+
}
93+
94+
assertProjectedJSONFields(t, out, []string{"incident_id", "title"})
95+
})
96+
97+
t.Run("explicit empty fields errors", func(t *testing.T) {
98+
saveAndResetGlobals(t)
99+
stub := newGFStub(t)
100+
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}
101+
102+
_, err := execCommand("incident", "list", "--fields", "", "--output-format", "json")
103+
if err == nil {
104+
t.Fatal("expected an error for empty --fields, got nil")
105+
}
106+
if !strings.Contains(err.Error(), "--fields") {
107+
t.Errorf("error should name --fields, got: %v", err)
108+
}
109+
})
110+
}
111+
112+
// TestAlertFieldsProjectionDefaultUnchanged is the conductor constraint for the
113+
// sibling command: with NO --fields, alert list structured output still emits
114+
// the full nested record. The compact default is incident-list-only.
115+
func TestAlertFieldsProjectionDefaultUnchanged(t *testing.T) {
48116
cases := []struct {
49117
name string
50118
cmd []string
51119
data map[string]any
52120
format string
53121
mustHave []string // nested keys that must survive in the full dump
54122
}{
55-
{"incident toon", []string{"incident", "list"}, incidentRow(), "toon", []string{"responders", "labels", "description"}},
56-
{"incident json", []string{"incident", "list"}, incidentRow(), "json", []string{"responders", "labels", "description"}},
57123
{"alert toon", []string{"alert", "list"}, alertRow(), "toon", []string{"events", "incident", "labels", "description"}},
58124
{"alert json", []string{"alert", "list"}, alertRow(), "json", []string{"events", "incident", "labels", "description"}},
59125
}
@@ -77,6 +143,27 @@ func TestFieldsProjectionDefaultUnchanged(t *testing.T) {
77143
}
78144
}
79145

146+
func assertProjectedJSONFields(t *testing.T, out string, fields []string) {
147+
t.Helper()
148+
149+
var rows []map[string]json.RawMessage
150+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
151+
t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out)
152+
}
153+
if len(rows) != 1 {
154+
t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out)
155+
}
156+
row := rows[0]
157+
if len(row) != len(fields) {
158+
t.Fatalf("expected exactly %d keys, got %d (%v)", len(fields), len(row), row)
159+
}
160+
for _, f := range fields {
161+
if _, ok := row[f]; !ok {
162+
t.Errorf("projected row missing key %q, got keys %v", f, row)
163+
}
164+
}
165+
}
166+
80167
// TestFieldsProjectionTOON: --fields in toon mode emits exactly the requested
81168
// keys and drops everything else.
82169
func TestFieldsProjectionTOON(t *testing.T) {
@@ -154,22 +241,7 @@ func TestFieldsProjectionJSON(t *testing.T) {
154241
t.Fatalf("execCommand: %v", err)
155242
}
156243

157-
var rows []map[string]json.RawMessage
158-
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
159-
t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out)
160-
}
161-
if len(rows) != 1 {
162-
t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out)
163-
}
164-
row := rows[0]
165-
if len(row) != len(tc.fields) {
166-
t.Fatalf("expected exactly %d keys, got %d (%v)", len(tc.fields), len(row), row)
167-
}
168-
for _, f := range tc.fields {
169-
if _, ok := row[f]; !ok {
170-
t.Errorf("projected row missing key %q, got keys %v", f, row)
171-
}
172-
}
244+
assertProjectedJSONFields(t, out, tc.fields)
173245
})
174246
}
175247
}

internal/cli/generic_table.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const maxHeuristicColumns = 8
2020
// can't blow out the table width.
2121
const genericStringMaxWidth = 40
2222

23+
const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Plugins -> MCP; tools will not appear until authorized."
24+
2325
// instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output
2426
// package's unexported instant) so the renderer can recognise timestamp fields
2527
// by reflection.
@@ -62,15 +64,18 @@ func renderGenericTable(ctx *RunContext, data any) error {
6264
if rows, total, ok := listEnvelope(v); ok {
6365
return renderRowTable(ctx, rows, total)
6466
}
65-
return renderVertical(ctx, v)
67+
if err := renderVertical(ctx, v); err != nil {
68+
return err
69+
}
70+
return renderMcpPerUserOAuthNotice(ctx, v)
6671
default:
6772
return jsonFallback(ctx, data)
6873
}
6974
}
7075

7176
// listEnvelope reports whether struct v is a paginated list envelope: exactly
72-
// one exported field that is a slice of structs (the rows), with the remaining
73-
// fields being pagination metadata. It returns the rows value and the total
77+
// one exported field that is a slice of structs (the rows), with any remaining
78+
// fields limited to pagination metadata. It returns the rows value and the total
7479
// (the int field named "Total" when present, else the row count).
7580
func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) {
7681
t := v.Type()
@@ -92,6 +97,9 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) {
9297
if total < 0 && f.Name == "Total" && fv.CanInt() {
9398
total = int(fv.Int())
9499
}
100+
if !isListMetadataField(f, fv) {
101+
return reflect.Value{}, 0, false
102+
}
95103
}
96104
if !found {
97105
return reflect.Value{}, 0, false
@@ -102,6 +110,22 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) {
102110
return rows, total, true
103111
}
104112

113+
func isListMetadataField(f reflect.StructField, fv reflect.Value) bool {
114+
if f.Anonymous && f.Name == "ListOptions" {
115+
return true
116+
}
117+
switch f.Name {
118+
case "Total":
119+
return fv.CanInt()
120+
case "HasNextPage":
121+
return fv.Kind() == reflect.Bool
122+
case "SearchAfterCtx", "NextCursor":
123+
return fv.Kind() == reflect.String
124+
default:
125+
return false
126+
}
127+
}
128+
105129
// isRowSlice reports whether t is a slice whose element (after pointer deref) is
106130
// a struct — i.e. a table-able row collection.
107131
func isRowSlice(t reflect.Type) bool {
@@ -210,6 +234,22 @@ func renderVertical(ctx *RunContext, v reflect.Value) error {
210234
return ctx.Printer.Print(rows, cols)
211235
}
212236

237+
func renderMcpPerUserOAuthNotice(ctx *RunContext, v reflect.Value) error {
238+
if !isMcpPerUserOAuth(v) {
239+
return nil
240+
}
241+
_, err := fmt.Fprintln(ctx.Writer, mcpPerUserOAuthNotice)
242+
return err
243+
}
244+
245+
func isMcpPerUserOAuth(v reflect.Value) bool {
246+
if v.Type().Name() != "McpServerItem" {
247+
return false
248+
}
249+
auth := v.FieldByName("AuthMode")
250+
return auth.IsValid() && auth.Kind() == reflect.String && auth.String() == "per_user_oauth"
251+
}
252+
213253
// derefStruct dereferences pointer chains and returns the underlying struct
214254
// reflect.Value. The second return is false when item is nil, not a struct after
215255
// dereferencing, or any pointer in the chain is nil.

internal/cli/generic_table_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,57 @@ func TestRenderGenericTable_DetailVertical(t *testing.T) {
144144
}
145145
}
146146

147+
func TestRenderGenericTable_McpServerItemWithEmptyToolsRendersDetail(t *testing.T) {
148+
var buf bytes.Buffer
149+
item := &flashduty.McpServerItem{
150+
ServerID: "mcp_test",
151+
ServerName: "github",
152+
Description: "GitHub connector",
153+
AuthMode: "shared",
154+
Status: "enabled",
155+
Transport: "streamable-http",
156+
URL: "https://mcp.example.com/github",
157+
Tools: []flashduty.McpToolInfo{},
158+
}
159+
if err := renderGenericTable(tableCtx(&buf), item); err != nil {
160+
t.Fatalf("render: %v", err)
161+
}
162+
got := buf.String()
163+
if strings.Contains(got, "No results.") {
164+
t.Fatalf("single MCP server item with empty tools was rendered as an empty list:\n%s", got)
165+
}
166+
for _, want := range []string{"FIELD", "VALUE", "SERVER_ID", "mcp_test", "SERVER_NAME", "github"} {
167+
if !strings.Contains(got, want) {
168+
t.Errorf("MCP server detail output missing %q\n---\n%s", want, got)
169+
}
170+
}
171+
}
172+
173+
func TestRenderGenericTable_McpServerPerUserOAuthNotice(t *testing.T) {
174+
var buf bytes.Buffer
175+
item := &flashduty.McpServerItem{
176+
ServerID: "mcp_oauth",
177+
ServerName: "github",
178+
Description: "GitHub connector",
179+
AuthMode: "per_user_oauth",
180+
Status: "enabled",
181+
Transport: "streamable-http",
182+
URL: "https://mcp.example.com/github",
183+
}
184+
if err := renderGenericTable(tableCtx(&buf), item); err != nil {
185+
t.Fatalf("render: %v", err)
186+
}
187+
got := buf.String()
188+
for _, want := range []string{
189+
"registered but not usable until OAuth is completed in Flashduty Plugins -> MCP",
190+
"tools will not appear until authorized",
191+
} {
192+
if !strings.Contains(got, want) {
193+
t.Errorf("per-user OAuth notice missing %q\n---\n%s", want, got)
194+
}
195+
}
196+
}
197+
147198
func TestPrintGenericResult_StructuredUnchanged(t *testing.T) {
148199
resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1}
149200

0 commit comments

Comments
 (0)