From 419f3d4d84feae877ebc42d725d85f434536702c Mon Sep 17 00:00:00 2001 From: pijiang <419471640@qq.com> Date: Wed, 12 Aug 2026 15:24:21 +0800 Subject: [PATCH 1/2] fix: preserve explicit notification preference false --- flashduty_test.go | 109 ++++++++++++++++++++++++++++++++++ internal/cmd/gen/main_test.go | 19 ++++++ models_gen.go | 32 ++++++++-- openapi/openapi.en.json | 39 ++++++++++-- openapi/openapi.zh.json | 39 ++++++++++-- 5 files changed, 226 insertions(+), 12 deletions(-) diff --git a/flashduty_test.go b/flashduty_test.go index 5179253..2ef86e2 100644 --- a/flashduty_test.go +++ b/flashduty_test.go @@ -2,6 +2,7 @@ package flashduty import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -133,6 +134,114 @@ func TestResetPostMortemContentSendsZeroExpectedRevision(t *testing.T) { } } +func TestIncidentNotificationOverridePreservesExplicitFalse(t *testing.T) { + c, _ := NewClient("KEY", WithBaseURL("https://api.flashcat.cloud"), WithLogger(noopLogger{})) + + tests := []struct { + name string + path string + body any + notifyPath []string + }{ + { + name: "create incident", + path: "/incident/create", + body: &CreateIncidentRequest{ + IncidentSeverity: "Critical", + AssignedTo: CreateIncidentRequestAssignedTo{ + PersonIDs: []int64{1}, + Notify: CreateIncidentRequestAssignedToNotify{ + FollowPreference: Bool(false), + PersonalChannels: []string{"sms"}, + }, + }, + }, + notifyPath: []string{"assigned_to", "notify"}, + }, + { + name: "add responder", + path: "/incident/responder/add", + body: &AddIncidentResponderRequest{ + IncidentID: "0123456789abcdef01234567", + PersonIDs: []int64{1}, + Notify: AddIncidentResponderRequestNotify{ + FollowPreference: Bool(false), + PersonalChannels: []string{"sms"}, + }, + }, + notifyPath: []string{"notify"}, + }, + { + name: "assign incident", + path: "/incident/assign", + body: &AssignIncidentRequest{ + IncidentID: "0123456789abcdef01234567", + AssignedTo: AssignedTo{ + PersonIDs: []int64{1}, + Notify: AssignedToNotify{ + FollowPreference: Bool(false), + PersonalChannels: []string{"sms"}, + }, + }, + }, + notifyPath: []string{"assigned_to", "notify"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := c.newRequest(context.Background(), http.MethodPost, tt.path, tt.body) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + notify := payload + for _, key := range tt.notifyPath { + value, ok := notify[key].(map[string]any) + if !ok { + t.Fatalf("%s is missing from request body: %s", key, body) + } + notify = value + } + follow, ok := notify["follow_preference"] + if !ok || follow != false { + t.Fatalf("follow_preference = %#v, present = %t, body = %s", follow, ok, body) + } + }) + } +} + +func TestIncidentNotificationOverrideOmitsUnsetPreference(t *testing.T) { + c, _ := NewClient("KEY", WithBaseURL("https://api.flashcat.cloud"), WithLogger(noopLogger{})) + req, err := c.newRequest(context.Background(), http.MethodPost, "/incident/create", &CreateIncidentRequest{ + IncidentSeverity: "Critical", + AssignedTo: CreateIncidentRequestAssignedTo{ + PersonIDs: []int64{1}, + Notify: CreateIncidentRequestAssignedToNotify{ + PersonalChannels: []string{"sms"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), `"follow_preference"`) { + t.Fatalf("nil FollowPreference must be omitted from the wire, got body = %s", body) + } +} + func TestNewRequestAppliesHookAndHeaders(t *testing.T) { c, _ := NewClient("KEY", WithRequestHeaders(map[string][]string{"X-Static": {"s"}}), diff --git a/internal/cmd/gen/main_test.go b/internal/cmd/gen/main_test.go index 2287f18..1ccd544 100644 --- a/internal/cmd/gen/main_test.go +++ b/internal/cmd/gen/main_test.go @@ -200,6 +200,25 @@ func TestEmitStructRequiredNullableRequestScalarOmitsNil(t *testing.T) { } } +func TestEmitStructOptionalNullableRequestBoolPreservesFalse(t *testing.T) { + g := newTestGen(map[string]any{}) + g.reqGoNames["NotifyRequest"] = true + + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "follow_preference": map[string]any{ + "type": []any{"boolean", "null"}, + }, + }, + } + + src := g.emitStruct("NotifyRequest", schema) + if !strings.Contains(src, `FollowPreference *bool `+"`"+`json:"follow_preference,omitempty" toon:"follow_preference,omitempty"`+"`") { + t.Fatalf("optional nullable request bool must preserve explicit false; got:\n%s", src) + } +} + func TestMergeAllOfKeepsRequiredRequestFields(t *testing.T) { g := newTestGen(map[string]any{ "BaseRequest": map[string]any{ diff --git a/models_gen.go b/models_gen.go index 29203dc..72c94ad 100644 --- a/models_gen.go +++ b/models_gen.go @@ -1062,6 +1062,8 @@ type AssignedTo struct { ID string `json:"id,omitempty" toon:"id,omitempty"` // Current level index within the escalation rule. LayerIdx int64 `json:"layer_idx,omitempty" toon:"layer_idx,omitempty"` + // Override the notification channels used for this assignment. + Notify AssignedToNotify `json:"notify,omitzero" toon:"notify,omitempty"` // Member IDs to assign directly. PersonIDs []int64 `json:"person_ids,omitempty" toon:"person_ids,omitempty"` // Assignment type: `assign` direct assignment, `reassign` reassignment, `escalate` escalation-rule driven, `reopen` automatic reassignment on reopen. @@ -2945,6 +2947,8 @@ type FeedDetailIncidentAssign struct { ID string `json:"id" toon:"id"` // Current level index within the escalation rule. LayerIdx int64 `json:"layer_idx" toon:"layer_idx"` + // Override the notification channels used for this assignment. + Notify FeedDetailIncidentAssignNotify `json:"notify" toon:"notify"` // Member IDs to assign directly. PersonIDs []int64 `json:"person_ids" toon:"person_ids"` // Member IDs that received the assignment. @@ -9938,8 +9942,8 @@ type AccountInfoRestrictions struct { // AddIncidentResponderRequestNotify is generated from the Flashduty OpenAPI schema. type AddIncidentResponderRequestNotify struct { - // When true, fall back to each responder's personal preference. - FollowPreference bool `json:"follow_preference,omitempty" toon:"follow_preference,omitempty"` + // When false, use `personal_channels`; when true or omitted, use each responder's personal preference. + FollowPreference *bool `json:"follow_preference,omitempty" toon:"follow_preference,omitempty"` // Channels to use (e.g. `voice`, `sms`, `email`). PersonalChannels []string `json:"personal_channels,omitempty" toon:"personal_channels,omitempty"` // Notification template ID (MongoDB ObjectID). @@ -9966,6 +9970,16 @@ type AlertRuleInfoResponseEnabledTimesItem struct { Stime string `json:"stime" toon:"stime"` } +// AssignedToNotify is generated from the Flashduty OpenAPI schema. +type AssignedToNotify struct { + // When false, use `personal_channels`; when true or omitted, use each responder's personal preference. + FollowPreference *bool `json:"follow_preference,omitempty" toon:"follow_preference,omitempty"` + // Channels to use (e.g. `voice`, `sms`, `email`). + PersonalChannels []string `json:"personal_channels,omitempty" toon:"personal_channels,omitempty"` + // Notification template ID (MongoDB ObjectID). + TemplateID string `json:"template_id,omitempty" toon:"template_id,omitempty"` +} + // AuditLogParamsItem is generated from the Flashduty OpenAPI schema. type AuditLogParamsItem struct { Key string `json:"Key" toon:"Key"` @@ -10216,6 +10230,16 @@ type EscalateTargetWebhooksItem struct { Type string `json:"type" toon:"type"` } +// FeedDetailIncidentAssignNotify is generated from the Flashduty OpenAPI schema. +type FeedDetailIncidentAssignNotify struct { + // When false, use `personal_channels`; when true or omitted, use each responder's personal preference. + FollowPreference bool `json:"follow_preference" toon:"follow_preference"` + // Channels to use (e.g. `voice`, `sms`, `email`). + PersonalChannels []string `json:"personal_channels" toon:"personal_channels"` + // Notification template ID (MongoDB ObjectID). + TemplateID string `json:"template_id" toon:"template_id"` +} + // FieldDeleteReferenceErrorData is generated from the Flashduty OpenAPI schema. type FieldDeleteReferenceErrorData struct { Refs []FieldDeleteReference `json:"refs" toon:"refs"` @@ -10512,8 +10536,8 @@ type CreateEscalationRuleRequestLayersItemTarget struct { // CreateIncidentRequestAssignedToNotify is generated from the Flashduty OpenAPI schema. type CreateIncidentRequestAssignedToNotify struct { - // When true, fall back to each responder's personal preference. - FollowPreference bool `json:"follow_preference,omitempty" toon:"follow_preference,omitempty"` + // When false, use `personal_channels`; when true or omitted, use each responder's personal preference. + FollowPreference *bool `json:"follow_preference,omitempty" toon:"follow_preference,omitempty"` // Channels to use (e.g. `voice`, `sms`, `email`). PersonalChannels []string `json:"personal_channels,omitempty" toon:"personal_channels,omitempty"` // Notification template ID (MongoDB ObjectID). diff --git a/openapi/openapi.en.json b/openapi/openapi.en.json index 5a2ec03..2426221 100644 --- a/openapi/openapi.en.json +++ b/openapi/openapi.en.json @@ -31156,8 +31156,11 @@ "description": "Override the notification channels used for this assignment.", "properties": { "follow_preference": { - "type": "boolean", - "description": "When true, fall back to each responder's personal preference." + "type": [ + "boolean", + "null" + ], + "description": "When false, use `personal_channels`; when true or omitted, use each responder's personal preference." }, "personal_channels": { "type": "array", @@ -31256,6 +31259,31 @@ "maxItems": 100, "description": "Email recipients, used by integrations such as ServiceNow." }, + "notify": { + "type": "object", + "description": "Override the notification channels used for this assignment.", + "properties": { + "follow_preference": { + "type": [ + "boolean", + "null" + ], + "description": "When false, use `personal_channels`; when true or omitted, use each responder's personal preference." + }, + "personal_channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channels to use (e.g. `voice`, `sms`, `email`)." + }, + "template_id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "description": "Notification template ID (MongoDB ObjectID)." + } + } + }, "escalate_rule_name": { "type": "string", "description": "Escalation rule display name, filled by the server." @@ -33632,8 +33660,11 @@ "description": "Optional notification override. Defaults to following each person's personal preference.", "properties": { "follow_preference": { - "type": "boolean", - "description": "When true, fall back to each responder's personal preference." + "type": [ + "boolean", + "null" + ], + "description": "When false, use `personal_channels`; when true or omitted, use each responder's personal preference." }, "personal_channels": { "type": "array", diff --git a/openapi/openapi.zh.json b/openapi/openapi.zh.json index e608fe2..9a14f0b 100644 --- a/openapi/openapi.zh.json +++ b/openapi/openapi.zh.json @@ -31148,8 +31148,11 @@ "description": "为本次指派覆盖的通知渠道。", "properties": { "follow_preference": { - "type": "boolean", - "description": "为 true 时跟随处理人员的个人偏好。" + "type": [ + "boolean", + "null" + ], + "description": "为 false 时使用 `personal_channels`;为 true 或省略时跟随处理人员的个人偏好。" }, "personal_channels": { "type": "array", @@ -31248,6 +31251,31 @@ "maxItems": 100, "description": "邮箱接收人列表,用于 ServiceNow 等外部系统。" }, + "notify": { + "type": "object", + "description": "为本次指派覆盖通知渠道。", + "properties": { + "follow_preference": { + "type": [ + "boolean", + "null" + ], + "description": "为 false 时使用 `personal_channels`;为 true 或省略时跟随处理人员的个人偏好。" + }, + "personal_channels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "使用的通知渠道(如 `voice`、`sms`、`email`)。" + }, + "template_id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "description": "通知模板 ID(MongoDB ObjectID)。" + } + } + }, "escalate_rule_name": { "type": "string", "description": "分派策略名称,由服务端填充。" @@ -33623,8 +33651,11 @@ "description": "通知配置,默认跟随每人的个人通知偏好。", "properties": { "follow_preference": { - "type": "boolean", - "description": "为 true 时跟随处理人员的个人偏好。" + "type": [ + "boolean", + "null" + ], + "description": "为 false 时使用 `personal_channels`;为 true 或省略时跟随处理人员的个人偏好。" }, "personal_channels": { "type": "array", From fc17618a8734cccf6cf2ac5437a0e564149e4806 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 12 Aug 2026 01:45:11 -0700 Subject: [PATCH 2/2] chore: ignore local-only scratch directories --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 194ee84..0365ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ # stray binary from `go build ./internal/cmd/gen` at repo root /gen + +# Local-only scratch: editor/agent state and working notes +docs/superpowers/ +.claude/