Skip to content

Commit 21ef2da

Browse files
authored
Merge pull request #161 from flashcatcloud/fix/template-update-semantics
docs(skill): template update replaces the whole object, not just passed fields
2 parents a10587c + e0f7b54 commit 21ef2da

2 files changed

Lines changed: 116 additions & 7 deletions

File tree

internal/skilldoc/source_cards_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,60 @@ func TestChannelCardDisambiguatesFlashcatWorkspace(t *testing.T) {
2424
}
2525
}
2626
}
27+
28+
// TestTemplateCardUpdateSemanticsArePartial pins the one fact this card has now had
29+
// backwards in both directions. POST /template/update binds every channel field as
30+
// *string and writes only the non-nil ones, so a channel absent from the request keeps
31+
// its stored body and only an explicit empty string clears it. The card once promised
32+
// omission preserved (true today, false then), was corrected to warn that omission
33+
// cleared (true then, false today), and this test exists so the correction does not
34+
// outlive the server behaviour that motivated it.
35+
func TestTemplateCardUpdateSemanticsArePartial(t *testing.T) {
36+
body, err := os.ReadFile("../../skills/flashduty/reference/template.md")
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
text := string(body)
41+
42+
// Claims that are false against the current server, in any phrasing.
43+
for _, banned := range []string{
44+
// the destructive reading, retired when update became a partial update
45+
"full-object replace",
46+
"is CLEARED",
47+
"every channel field you do not pass is written",
48+
// buildTemplateUpdates covers 14 channel-content fields, not 16.
49+
"16 channel fields",
50+
// status is not a field of update's request at all, so it is not something
51+
// that "survives" omission alongside the pointer-typed inputs.
52+
"and `status` survive omission",
53+
} {
54+
if strings.Contains(text, banned) {
55+
t.Errorf("template card asserts %q; update writes only the fields the request carries", banned)
56+
}
57+
}
58+
59+
for _, want := range []string{
60+
// the semantics, stated as a partial update
61+
"partial update",
62+
"leaves it alone",
63+
// clearing is now an explicit act, and the sharp edge is that older builds
64+
// dropped an empty-string flag before it reached the wire, making a clear a
65+
// silent no-op. Both halves have to stay on the card.
66+
"explicit empty string",
67+
"v1.4.2",
68+
"14 channel-content fields",
69+
"feishu_app_card_v2_table_enabled",
70+
"info --json",
71+
// The write path must move bodies with jq, never through command substitution,
72+
// which strips every trailing newline off a template body.
73+
"--rawfile",
74+
"--data -",
75+
`"$(cat`,
76+
"strips *all* trailing newlines",
77+
"--feishu-app-card-v2-table-enabled",
78+
} {
79+
if !strings.Contains(text, want) {
80+
t.Errorf("template card missing %q", want)
81+
}
82+
}
83+
}

skills/flashduty/reference/template.md

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Prereq: `SKILL.md` read. Read verbs are free. `create`, `update`, `delete` mutate account-wide notification templates — confirm before running. `delete <template-id>` is **irreversible**.
44

5+
**`update` writes only the fields you send.** A channel you omit keeps its current
6+
content; a channel you send as an empty string is cleared. So a one-channel edit sends one
7+
channel — but read the clearing caveat under Gotchas before you try to empty one.
8+
59
## Route here when
610

711
"通知模板 / 消息模板 / 告警通知格式 / 飞书模板 / Slack 模板 / 邮件模板 / template CRUD / custom template / preview notification / validate template" → **template**. NOT `channel` (channel = escalation policy routing; template = the rendered text/card body). The key ID is **`template_id`** (string), returned by `list` or `create`.
@@ -46,15 +50,41 @@ fduty template create \
4650
fduty template info <template-id> --output-format toon
4751
```
4852

49-
## Hot flow — update one channel on an existing template
53+
## Hot flow — change one channel on an existing template
54+
55+
`update` patches, so you send only the channel you are changing. What still bites is the
56+
*body*: carry it with `jq --rawfile` and `--data -`, never through `"$(...)"`.
5057

5158
```bash
52-
# template-id is POSITIONAL; --template-name is required even on update
53-
fduty template update <template-id> \
54-
--template-name "Critical-Feishu-v2" \
55-
--feishu "$(cat ./feishu-v3.tpl)"
59+
T=<template-id> # POSITIONAL on update/info/delete; --template-name always required
60+
61+
# 1. Pull the current source of the channel you are changing
62+
fduty template info "$T" --json > /tmp/tpl.json
63+
jq -r '.feishu_app' /tmp/tpl.json > /tmp/feishu_app.tpl
64+
# …edit /tmp/feishu_app.tpl…
65+
66+
# 2. Preview the edited source against a REAL incident before writing
67+
jq -n --rawfile c /tmp/feishu_app.tpl \
68+
'{type:"feishu_app", content:$c, incident_id:"<incident-id>"}' \
69+
| fduty template preview --data -
70+
71+
# 3. Write that one channel. NEVER move the body through "$(...)": command substitution
72+
# strips every trailing newline, so a body ending in a blank line is silently shortened.
73+
jq -n --rawfile feishu_app /tmp/feishu_app.tpl \
74+
--arg t "$T" --arg n "<template-name>" \
75+
'{template_id:$t, template_name:$n, feishu_app:$feishu_app}' \
76+
| fduty template update --data -
77+
78+
# 4. Verify the body round-tripped byte-for-byte — cmp catches a silent truncation that
79+
# "the field is still non-empty" would not.
80+
fduty template info "$T" --json | jq -r '.feishu_app' | cmp - /tmp/feishu_app.tpl \
81+
&& echo "round-trip OK"
5682
```
5783

84+
Every channel you did not name is untouched — that is the server contract now, not luck.
85+
Verify the body you wrote anyway: `cmp` is what separates "wrote the right bytes" from
86+
"wrote something non-empty".
87+
5888
<!-- GENERATED:template START · 由 fduty __dump-commands 同步 · 勿手改 fence 内 -->
5989

6090
### create
@@ -166,8 +196,30 @@ Note: `create` / `update` flags use **hyphenated** names (`--dingtalk-app`, `--f
166196
## Gotchas
167197

168198
- **`info`, `update`, `delete` take `<template-id>` as a positional first argument** — pass it bare, not as `--template-id`. `create`, `list`, `preview`, `validate`, `get-preset`, `functions`, `variables` take all inputs as flags.
169-
- **`update` replaces every channel field you pass — omitted channel flags are left unchanged** (server behavior: only supplied fields overwrite). Always pass `--template-name` even if the name is unchanged — it is required on update.
170-
- **`--feishu-app-card-table-enabled` uses pointer semantics on `update`** — unlike the plain string channel-content flags, it patches the table-rendering setting only when the flag is explicitly passed; omit it to leave the existing setting untouched. It is a plain bool on `create` (no prior setting to preserve).
199+
- **`update` is a partial update: omitting a field leaves it alone.** The server writes
200+
only what the request contains, so naming one channel rewrites that channel and nothing
201+
else. All 14 channel-content fields plus `description`, `team_id`,
202+
`feishu_app_card_v2_table_enabled` and `incident_card_hidden_fields` behave this way.
203+
(`status` is not part of `update`'s request at all — it moves only through the separate
204+
enable/disable endpoints, which the CLI does not expose — so `update` can never change
205+
it.) `--template-name` is still required on every update even when unchanged.
206+
- **To CLEAR a channel you must send it as an explicit empty string** — omitting it now
207+
means "keep", not "clear". `--dingtalk-app ''` is the intent, but a flag set to the empty
208+
string was dropped before it reached the wire in `fduty` **older than v1.4.2**, which
209+
makes clearing a silent no-op on those builds. Check `fduty --version` first; if it is
210+
older, clear via `--data` with the field spelled out — `--data '{"template_id":"…",
211+
"template_name":"…","dingtalk_app":""}'` — and confirm with `info --json` that the
212+
channel actually went empty.
213+
- **Never move a template body through `"$(cat …)"` or `"$(jq -r …)"`.** Bash command
214+
substitution strips *all* trailing newlines, so a body that legitimately ends in a blank
215+
line is written back shortened — and a check that only asks which fields are non-empty
216+
cannot see it, because the field is still non-empty. Carry bodies with `jq --rawfile`
217+
and write with `--data -`, as the hot flow does.
218+
- **`--feishu-app-card-v2-table-enabled` uses pointer semantics on `update`** — unlike the plain string channel-content flags, it patches the table-rendering setting only when the flag is explicitly passed; omit it to leave the existing setting untouched. It is a plain bool on `create` (no prior setting to preserve).
219+
- **`list` returns every channel's full template source for every row** — a few dozen
220+
templates blow past a tool-output cap in one call. Never render it directly: go to a
221+
file and project. `fduty template list --limit 100 --json > /tmp/tpl_list.json && jq -r
222+
'.items[] | [.template_id, .template_name, .team_id] | @tsv' /tmp/tpl_list.json`.
171223
- **`delete` is permanent.** The built-in preset (`template_id = 000000000000000000000001`) can be addressed by that sentinel ID in `info` and `delete` — don't delete it.
172224
- **`validate` reads from a local `--file`; `preview` takes inline `--content`.** They are complementary: `validate` gives size-vs-limit diagnostics; `preview` renders against real or mock incident data.
173225
- **`email` uses `html/template` syntax; `sms` and `voice` use `text/template`** — auto-escaping rules differ. Don't mix them.

0 commit comments

Comments
 (0)