Skip to content

feat(incident): make incident comment report what it already verified - #247

Open
ysyneu wants to merge 2 commits into
feat/cli-incident-commentfrom
cli/incident-comment-returns-entry
Open

ysyneu wants to merge 2 commits into
feat/cli-incident-commentfrom
cli/incident-comment-returns-entry

Conversation

@ysyneu

@ysyneu ysyneu commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

incident comment posts to up to 100 incidents in one batch POST, then re-reads every target's /incident/feed to confirm an i_comm entry matching the sent text is present. That read-back already exists. Two things about it stopped a caller from trusting it, and both end the same way — the caller reads the timeline anyway, which is the round trip the read-back was supposed to remove.

1. The entry it verified was thrown away

The read-back reduced the entry it found to a bool, and the command printed a count. So it could answer "did it land?" and nothing else; learning what landed took a second flashduty incident timeline <id> call plus feed parsing reinvented in every caller.

It now returns the entry. With --json or --output-format toon, one object per incident in argument order:

[
  {
    "incident_id": "69da451ef77b1b51f40e83ee",
    "comment": "Root cause identified: DB failover.\nFix deploying.",
    "creator_id": 2476444212131,
    "created_at": "2026-11-10T00:00:00+08:00"
  }
]
[2]{incident_id,comment,creator_id,created_at}:
  69da451ef77b1b51f40e83ee,"Root cause identified: DB failover.",2476444212131,"2026-11-10T00:00:00+08:00"
  69da451ef77b1b51f40e83ef,"Root cause identified: DB failover.",2476444212131,"2026-11-10T00:00:00+08:00"
  • No new flag. The read-back is not optional and never was, so there is no second path to select between; the command returns what it already went and fetched.
  • A top-level array at every batch size. A one-incident call is a one-element array, not a bare object, so a caller never branches on arity.
  • Four fields, because that is all there is. POST /incident/comment answers with an empty data envelope — no id, no echo — and feed entries carry no per-entry id (IncidentFeedItem has account_id, created_at, creator_id, deleted_at, detail, ref_id, type, updated_at, none of which identifies the comment). There is no comment id to return.
  • incident_id is the queried id, not item.ref_id, which is documented as the source alert or incident the entry references.

2. The output never said the verification happened

The result line said only that a comment was sent. The exit code already carried the verdict, but an exit code is not visible evidence: nothing in the output told a reader the command had confirmed the comment on the timeline. The line now says it:

$ flashduty incident comment <id1> <id2> --comment-file ./comment.txt
Commented on 2 incident(s); confirmed on each timeline.

That is the first and only line of default output, so it survives a | head. The per-entry detail stays in --json / --output-format toon rather than becoming extra lines above the verdict.

3. Mentions were compared by a label the server does not keep

A member mention is written [@Label](flashduty://ref/member/<member-id>). POST /incident/comment specifies that "the server rewrites each mention's display label to the member's canonical name", and its comment field is specified as "at most 1024 characters (counted after @mention normalization)". The timeline refreshes the label again from current member names when it serves the entry. So the stored text of a comment written perfectly is not the text that was sent.

The read-back compared the two literally, so any comment carrying a mention whose label was not already the canonical name was reported unconfirmed on a write that fully succeeded — and a reported failure is what sends a caller back to the timeline by hand.

Each mention is now reduced to a label-free form on both sides before comparing. Scope of the change:

stored vs sent matches
[@Jane] → [@Jane Doe], same member id yes
canonical name with escaped punctuation, [@Jane\]\*\_Doe] yes
several mentions, all labels rewritten yes
different member id no
any other text difference in the body no

The label pattern accepts backslash escapes because the canonical name arrives with its ASCII punctuation escaped, so a name containing ] would otherwise cut the match short. The returned entry still carries the stored text, canonical labels included — which is where a caller reads back the real names.

Unchanged by design: no CreatedAt floor (the comment on the finder explains why clock ordering stays out of this), and the documented older-identical-text limitation stands, widened by one mention label.

Where the change lives

The curated command in internal/cli/incident.go, which wins the comment name over its generated twin at registration. Both generators (cligen, the skill-card generator) were re-run: zero drift, so regeneration cannot wipe this.

Behavior change

Structured output for this command used to be {"message": "Commented on N incident(s)."}. A script reading .message must read the entries instead. The default-output line changed text as shown above. Nothing in this repo consumed either shape.

Tests

In internal/cli/command_test.go, against the existing go-flashduty stub:

  • entry returned, with JSON and TOON subtests; created_at parses as RFC3339 back to the feed entry's instant
  • entries follow argument order even when the stub makes completion order the exact reverse
  • nothing emitted on stdout when any incident in the batch cannot be confirmed
  • the confirmation is the whole of default output — asserted as equality, since Contains cannot catch a line pushed down by output added above it
  • mentions: the five rows of the table above
  • seven pre-existing assertions repointed from the old literal to the message constant

make is green: gofmt, gci, golangci-lint run with 0 issues, go test -race ./... across all packages, build. make check-cards OK.

The e2e/ suite is behind //go:build e2e and needs a live app key; it was compile-checked (go vet -tags e2e ./e2e/) but not run, and no comment was posted to any real incident. Both output shapes, the mention rewrite, the | head behavior and the non-zero failure path were exercised end to end against a local stub HTTP server with the built binary.

Docs

skills/flashduty/reference/incident.md records the new output line, the array of entries, and the mention comparison rule.

`incident comment` already re-read every target's timeline after writing, to
confirm an entry with the exact sent text was present. It then discarded what
it found and printed only a count, so learning what actually landed — the
stored body, the author the server recorded, the timestamp — still took a
second `incident timeline` call plus ad-hoc feed parsing in every caller.

The read-back now returns the entry it located and the command emits it: with
--json or --output-format toon, one object per incident in argument order
carrying incident_id, comment, creator_id and created_at. Table mode still
prints `Commented on N incident(s).`, and the failure path is unchanged — an
unconfirmed incident anywhere in the batch still exits non-zero with the same
anti-retry guidance, and prints no partial array.

The shape is a top-level array at every batch size, so a caller never has to
branch on arity. POST /incident/comment answers with an empty data envelope
and feed entries carry no per-entry id, so those four fields are the whole of
what any endpoint can report about a stored comment; there is no comment id to
return.

Behavior change for structured output: this command used to print
{"message": "Commented on N incident(s)."} under --json. A script reading
.message must read the entries instead.
…ns by member id

Two gaps in `incident comment`'s read-after-write check, both of which still
left a caller reading the timeline afterwards.

The result line said only that a comment was sent. The command had verified it
on every target's timeline, but nothing in the output said so, and an exit
code is not visible evidence — so a reader had no way to tell from the output
that the check ran, and went and read the timeline anyway. The line now names
the confirmation: `Commented on 2 incident(s); confirmed on each timeline.`
It is the first and only line of default output, so it survives a `| head`,
and the per-entry detail stays in --json / --output-format toon rather than
becoming extra lines above it.

Mentions were compared by display label, which the server does not keep. A
mention written as [@Label](flashduty://ref/member/<id>) has its label
replaced with the member's canonical name on write, and refreshed from current
member names again when the timeline is served — both documented on
POST /incident/comment, whose `comment` field is also specified as counted
"after @mention normalization". Comparing stored text to sent text literally
therefore reported a fully successful write as unconfirmed whenever the
caller's label was not already the canonical name, and a reported failure is
exactly what sends a caller back to the timeline by hand.

Each mention is now reduced to a label-free form on both sides before
comparing, so a rewritten label matches while a different member id does not,
and no other difference in the body is forgiven. The label pattern accepts
backslash escapes, because the canonical name arrives with its ASCII
punctuation escaped and a name containing "]" would otherwise cut the match
short. The returned entry still carries the stored text, canonical labels
included, which is what a caller asking what landed wants to read.

No CreatedAt floor was added and the older-identical-text limitation is
unchanged; canonicalization widens it by one mention label.
@ysyneu ysyneu changed the title feat(incident): return the verified timeline entry from incident comment feat(incident): make incident comment report what it already verified Nov 10, 2026
Sign up for free to 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