Skip to content

Commit 6b60791

Browse files
committed
feat(incident): add post-mortem-content-reset on go-flashduty v0.5.11
Replace the collaborative Markdown body of a drafting post-mortem report in one shot via the new POST /incident/post-mortem/content/reset endpoint. - Bump go-flashduty to v0.5.11 (drops the local-dev replace directive) and regenerate the CLI surface (cligen), which also picks up the new incident sdp-request-list operation. - --markdown-file reads a file or "-" for stdin; content is sent as-is and empty Markdown is rejected. --idempotency-key is required (max 128 Unicode characters). - --expected-revision is optional: when omitted, the CLI first resolves the document's current revision from GET /incident/post-mortem/info (data.meta.revision) and resets against it; when passed, the value is used verbatim for strict optimistic concurrency control. Negative values are rejected before any request. Note: go-flashduty v0.5.11's typed PostMortemMeta does not expose the revision field the server returns, so the revision lookup reads the info envelope directly through the SDK client's base URL and the same credential resolution as defaultNewClient (broker mode included). A TODO marks the switch to the typed SDK call once the field is exposed. Verified end-to-end against production with a deliberately stale --expected-revision: the API answered 409 Conflict (expected_revision conflict: request has 1 but current revision is 16), proving the full path without writing any data.
1 parent 7acc3f3 commit 6b60791

13 files changed

Lines changed: 881 additions & 40 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/go-flashduty v0.5.8-0.20260722114743-be934aea6779
6+
github.com/flashcatcloud/go-flashduty v0.5.11
77
github.com/mattn/go-runewidth v0.0.24
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.5.7 h1:bTgs4wN2mLMaVi8tuPDdR+8ZA6Dd4y6SKeZD+KhL4w8=
5-
github.com/flashcatcloud/go-flashduty v0.5.7/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
6-
github.com/flashcatcloud/go-flashduty v0.5.8-0.20260722113601-d9766098937e h1:ZJ2FVBRoKfgQmqYHSQXw+cBjWiDVUKnB/8+/PUgZ+zw=
7-
github.com/flashcatcloud/go-flashduty v0.5.8-0.20260722113601-d9766098937e/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
8-
github.com/flashcatcloud/go-flashduty v0.5.8-0.20260722114743-be934aea6779 h1:gUCg7EqrYbxYn1Z7jeNqpLWovPaNin9fdhTPgF093Dk=
9-
github.com/flashcatcloud/go-flashduty v0.5.8-0.20260722114743-be934aea6779/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
4+
github.com/flashcatcloud/go-flashduty v0.5.11 h1:80v7yWfoq0/66kq0qSsSdcb0ChiR/vzFAl/N0JI7xJA=
5+
github.com/flashcatcloud/go-flashduty v0.5.11/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
106
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
117
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
128
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=

internal/cli/command_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func saveAndResetGlobals(t *testing.T) {
2727
origUpdateNotice := updateNotice
2828
origUpdateCheckWarning := updateCheckWarning
2929
origStdinReader := stdinReader
30+
origCurrentPostMortemRevisionFn := currentPostMortemRevisionFn
3031

3132
// Reset to defaults so tests start clean.
3233
flagJSON = false
@@ -36,6 +37,7 @@ func saveAndResetGlobals(t *testing.T) {
3637
flagOutputFormat = ""
3738
updateNotice = nil
3839
updateCheckWarning = ""
40+
currentPostMortemRevisionFn = fetchCurrentPostMortemRevision
3941

4042
t.Cleanup(func() {
4143
newClientFn = origNewClientFn
@@ -47,6 +49,7 @@ func saveAndResetGlobals(t *testing.T) {
4749
updateNotice = origUpdateNotice
4850
updateCheckWarning = origUpdateCheckWarning
4951
stdinReader = origStdinReader
52+
currentPostMortemRevisionFn = origCurrentPostMortemRevisionFn
5053
})
5154
}
5255

internal/cli/incident.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func newIncidentCmd() *cobra.Command {
4444
cmd.AddCommand(newIncidentWarRoomCmd())
4545
cmd.AddCommand(newIncidentFeedCmd())
4646
cmd.AddCommand(newIncidentDetailCmd())
47+
cmd.AddCommand(newIncidentPostMortemContentResetCmd())
4748
return cmd
4849
}
4950

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"strconv"
11+
"strings"
12+
"time"
13+
"unicode/utf8"
14+
15+
"github.com/flashcatcloud/go-flashduty"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
const maxPostMortemContentIdempotencyKeyRunes = 128
20+
21+
func newIncidentPostMortemContentResetCmd() *cobra.Command {
22+
var (
23+
markdownFile string
24+
expectedRevision int64
25+
idempotencyKey string
26+
)
27+
28+
cmd := &cobra.Command{
29+
Use: "post-mortem-content-reset <post-mortem-id>",
30+
Short: "Reset post-mortem Markdown content",
31+
Long: curatedLong(
32+
"Replace the collaborative Markdown body of a drafting post-mortem report in one shot.\n\n"+
33+
"Read Markdown from --markdown-file (or \"-\" for stdin). The entire file is sent as-is; leading and trailing content is preserved. Empty Markdown is rejected.\n\n"+
34+
"--expected-revision guards against overwriting a concurrent edit: the reset only succeeds when the document's current revision equals it, and 0 is valid (first write / empty document). Negative values are rejected. When omitted, the CLI first fetches the report's current revision via the post-mortem info endpoint and uses that; pass it explicitly for strict concurrency control, when the caller already holds a revision and must fail on any intervening write.\n\n"+
35+
"--idempotency-key is required, must be non-empty, and at most 128 Unicode characters.",
36+
"Incidents",
37+
"PostMortemWriteResetContent",
38+
),
39+
Args: requireExactArg("post-mortem-id"),
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
return runCommand(cmd, args, func(ctx *RunContext) error {
42+
markdown, err := readPostMortemMarkdownFile(markdownFile)
43+
if err != nil {
44+
return err
45+
}
46+
if err := validatePostMortemContentResetFlags(idempotencyKey); err != nil {
47+
return err
48+
}
49+
50+
revision := expectedRevision
51+
if cmd.Flags().Changed("expected-revision") {
52+
if revision < 0 {
53+
return fmt.Errorf("--expected-revision must be >= 0")
54+
}
55+
} else {
56+
revision, err = currentPostMortemRevisionFn(ctx, ctx.Args[0])
57+
if err != nil {
58+
return err
59+
}
60+
}
61+
62+
out, _, err := ctx.Client.Incidents.PostMortemWriteResetContent(cmdContext(ctx.Cmd), &flashduty.ResetPostMortemContentRequest{
63+
PostMortemID: ctx.Args[0],
64+
Markdown: markdown,
65+
ExpectedRevision: flashduty.Int64(revision),
66+
IdempotencyKey: idempotencyKey,
67+
})
68+
if err != nil {
69+
return err
70+
}
71+
72+
human := fmt.Sprintf(
73+
"Reset post-mortem content for %s: generation %d→%d, revision %d→%d",
74+
out.PostMortemID,
75+
out.PreviousGeneration,
76+
out.Generation,
77+
out.PreviousRevision,
78+
out.Revision,
79+
)
80+
return ctx.WriteResultJSON(out, human)
81+
})
82+
},
83+
}
84+
85+
cmd.Flags().StringVar(&markdownFile, "markdown-file", "", "Path to Markdown content, or \"-\" to read stdin (required)")
86+
cmd.Flags().Int64Var(&expectedRevision, "expected-revision", 0, "Expected document revision; 0 is valid (optional: when omitted, the current revision is fetched via post-mortem info first)")
87+
cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "Idempotency key for safe retries; max 128 Unicode characters (required)")
88+
_ = cmd.MarkFlagRequired("markdown-file")
89+
_ = cmd.MarkFlagRequired("idempotency-key")
90+
91+
return cmd
92+
}
93+
94+
func validatePostMortemContentResetFlags(idempotencyKey string) error {
95+
if idempotencyKey == "" {
96+
return fmt.Errorf("--idempotency-key must not be empty")
97+
}
98+
if utf8.RuneCountInString(idempotencyKey) > maxPostMortemContentIdempotencyKeyRunes {
99+
return fmt.Errorf("--idempotency-key must be at most %d Unicode characters", maxPostMortemContentIdempotencyKeyRunes)
100+
}
101+
return nil
102+
}
103+
104+
// currentPostMortemRevisionFn resolves the current collaboration revision of a
105+
// post-mortem report. It is a package variable so tests can stub it.
106+
var currentPostMortemRevisionFn = fetchCurrentPostMortemRevision
107+
108+
// fetchCurrentPostMortemRevision GETs /incident/post-mortem/info and reads
109+
// data.meta.revision. go-flashduty v0.5.11's typed PostMortemMeta does not
110+
// expose the revision field yet (the server returns it), so this request goes
111+
// out directly, reusing the SDK client's base URL and the same credential
112+
// resolution as defaultNewClient — including broker mode, where the sentinel
113+
// app_key is overwritten by the broker as the request egresses.
114+
//
115+
// TODO: switch to ctx.Client.Incidents.PostMortemInfo once go-flashduty
116+
// exposes meta.revision on PostMortemMeta.
117+
func fetchCurrentPostMortemRevision(ctx *RunContext, postMortemID string) (int64, error) {
118+
cfg, err := loadResolvedConfig()
119+
if err != nil {
120+
return 0, err
121+
}
122+
123+
appKey := cfg.AppKey
124+
hc := &http.Client{Timeout: 30 * time.Second}
125+
if fdStr := os.Getenv("FLASHDUTY_CRED_FD"); fdStr != "" {
126+
fd, perr := strconv.Atoi(fdStr)
127+
// fds 0/1/2 are stdio; see defaultNewClient.
128+
if perr != nil || fd < 3 {
129+
return 0, fmt.Errorf("invalid FLASHDUTY_CRED_FD=%q", fdStr)
130+
}
131+
bc := newBrokerHTTPClient(fd)
132+
if bc == nil {
133+
return 0, errBrokerUnsupported
134+
}
135+
hc = bc
136+
appKey = "broker-sentinel"
137+
} else if appKey == "" {
138+
return 0, fmt.Errorf("no app key configured. Run 'flashduty login' or set FLASHDUTY_APP_KEY")
139+
}
140+
141+
rel, err := url.Parse("incident/post-mortem/info")
142+
if err != nil {
143+
return 0, fmt.Errorf("failed to build post-mortem info URL: %w", err)
144+
}
145+
u := ctx.Client.BaseURL.ResolveReference(rel)
146+
q := u.Query()
147+
q.Set("post_mortem_id", postMortemID)
148+
q.Set("app_key", appKey)
149+
u.RawQuery = q.Encode()
150+
151+
req, err := http.NewRequestWithContext(cmdContext(ctx.Cmd), http.MethodGet, u.String(), nil)
152+
if err != nil {
153+
return 0, fmt.Errorf("failed to build post-mortem info request: %w", err)
154+
}
155+
req.Header.Set("Accept", "application/json")
156+
157+
resp, err := hc.Do(req)
158+
if err != nil {
159+
return 0, fmt.Errorf("failed to fetch post-mortem info for %s: %w", postMortemID, err)
160+
}
161+
defer func() { _ = resp.Body.Close() }()
162+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
163+
if err != nil {
164+
return 0, fmt.Errorf("failed to read post-mortem info response: %w", err)
165+
}
166+
167+
var env struct {
168+
Error *struct {
169+
Code string `json:"code"`
170+
Message string `json:"message"`
171+
} `json:"error"`
172+
Data struct {
173+
Meta struct {
174+
Revision *int64 `json:"revision"`
175+
} `json:"meta"`
176+
} `json:"data"`
177+
}
178+
if err := json.Unmarshal(body, &env); err != nil {
179+
return 0, fmt.Errorf("failed to decode post-mortem info response: %w", err)
180+
}
181+
if resp.StatusCode != http.StatusOK || (env.Error != nil && env.Error.Code != "" && env.Error.Code != "OK") {
182+
msg := http.StatusText(resp.StatusCode)
183+
if env.Error != nil && env.Error.Message != "" {
184+
msg = env.Error.Message
185+
}
186+
return 0, fmt.Errorf("failed to fetch post-mortem info for %s: %s", postMortemID, msg)
187+
}
188+
if env.Data.Meta.Revision == nil {
189+
return 0, fmt.Errorf("post-mortem info for %s did not report a revision; pass --expected-revision explicitly", postMortemID)
190+
}
191+
return *env.Data.Meta.Revision, nil
192+
}
193+
194+
// readPostMortemMarkdownFile loads Markdown bytes without trimming. Path "-"
195+
// reads the injectable stdinReader so tests never touch the real stdin and
196+
// absent flags never block on an empty pipe.
197+
func readPostMortemMarkdownFile(path string) (string, error) {
198+
path = strings.TrimSpace(path)
199+
if path == "" {
200+
return "", fmt.Errorf("--markdown-file is required")
201+
}
202+
203+
var (
204+
b []byte
205+
err error
206+
)
207+
if path == "-" {
208+
b, err = io.ReadAll(stdinReader)
209+
if err != nil {
210+
return "", fmt.Errorf("failed to read markdown from stdin: %w", err)
211+
}
212+
} else {
213+
b, err = os.ReadFile(path)
214+
if err != nil {
215+
return "", fmt.Errorf("failed to read markdown file: %w", err)
216+
}
217+
}
218+
if len(b) == 0 {
219+
return "", fmt.Errorf("markdown content must not be empty")
220+
}
221+
return string(b), nil
222+
}

0 commit comments

Comments
 (0)