Skip to content

Commit e621445

Browse files
authored
Merge pull request #113 from flashcatcloud/codex/postmortem-reset-content-cli
feat(incident): add post-mortem-content-reset on go-flashduty v0.5.11
2 parents 7acc3f3 + ef5c89a commit e621445

16 files changed

Lines changed: 750 additions & 44 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.12
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.12 h1:gVTQX0IYQN/httUlTw4PZMjFOgjf8eM0/HjnIgbRnzo=
5+
github.com/flashcatcloud/go-flashduty v0.5.12/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: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"strings"
8+
"unicode/utf8"
9+
10+
"github.com/flashcatcloud/go-flashduty"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
const maxPostMortemContentIdempotencyKeyRunes = 128
15+
16+
func newIncidentPostMortemContentResetCmd() *cobra.Command {
17+
var (
18+
markdownFile string
19+
expectedRevision int64
20+
idempotencyKey string
21+
)
22+
23+
cmd := &cobra.Command{
24+
Use: "post-mortem-content-reset <post-mortem-id>",
25+
Short: "Reset post-mortem Markdown content",
26+
Long: curatedLong(
27+
"Replace the collaborative Markdown body of a drafting post-mortem report in one shot.\n\n"+
28+
"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"+
29+
"--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"+
30+
"--idempotency-key is required, must be non-empty, and at most 128 Unicode characters.",
31+
"Incidents",
32+
"PostMortemWriteResetContent",
33+
),
34+
Args: requireExactArg("post-mortem-id"),
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
return runCommand(cmd, args, func(ctx *RunContext) error {
37+
markdown, err := readPostMortemMarkdownFile(markdownFile)
38+
if err != nil {
39+
return err
40+
}
41+
if err := validatePostMortemContentResetFlags(idempotencyKey); err != nil {
42+
return err
43+
}
44+
45+
revision := expectedRevision
46+
if cmd.Flags().Changed("expected-revision") {
47+
if revision < 0 {
48+
return fmt.Errorf("--expected-revision must be >= 0")
49+
}
50+
} else {
51+
revision, err = currentPostMortemRevisionFn(ctx, ctx.Args[0])
52+
if err != nil {
53+
return err
54+
}
55+
}
56+
57+
out, _, err := ctx.Client.Incidents.PostMortemWriteResetContent(cmdContext(ctx.Cmd), &flashduty.ResetPostMortemContentRequest{
58+
PostMortemID: ctx.Args[0],
59+
Markdown: markdown,
60+
ExpectedRevision: flashduty.Int64(revision),
61+
IdempotencyKey: idempotencyKey,
62+
})
63+
if err != nil {
64+
return err
65+
}
66+
67+
human := fmt.Sprintf(
68+
"Reset post-mortem content for %s: generation %d→%d, revision %d→%d",
69+
out.PostMortemID,
70+
out.PreviousGeneration,
71+
out.Generation,
72+
out.PreviousRevision,
73+
out.Revision,
74+
)
75+
return ctx.WriteResultJSON(out, human)
76+
})
77+
},
78+
}
79+
80+
cmd.Flags().StringVar(&markdownFile, "markdown-file", "", "Path to Markdown content, or \"-\" to read stdin (required)")
81+
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)")
82+
cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", "Idempotency key for safe retries; max 128 Unicode characters (required)")
83+
_ = cmd.MarkFlagRequired("markdown-file")
84+
_ = cmd.MarkFlagRequired("idempotency-key")
85+
86+
return cmd
87+
}
88+
89+
func validatePostMortemContentResetFlags(idempotencyKey string) error {
90+
if idempotencyKey == "" {
91+
return fmt.Errorf("--idempotency-key must not be empty")
92+
}
93+
if utf8.RuneCountInString(idempotencyKey) > maxPostMortemContentIdempotencyKeyRunes {
94+
return fmt.Errorf("--idempotency-key must be at most %d Unicode characters", maxPostMortemContentIdempotencyKeyRunes)
95+
}
96+
return nil
97+
}
98+
99+
// currentPostMortemRevisionFn resolves the current collaboration revision of a
100+
// post-mortem report. It is a package variable so tests can stub it.
101+
var currentPostMortemRevisionFn = fetchCurrentPostMortemRevision
102+
103+
// fetchCurrentPostMortemRevision reads the report's current collaboration
104+
// revision via the typed SDK (data.meta.revision on PostMortemInfo, exposed
105+
// since go-flashduty v0.5.12). Revision 0 is a legitimate value — a document
106+
// that has never been saved — so no presence check is needed.
107+
func fetchCurrentPostMortemRevision(ctx *RunContext, postMortemID string) (int64, error) {
108+
info, _, err := ctx.Client.Incidents.PostMortemInfo(cmdContext(ctx.Cmd), &flashduty.IncidentsPostMortemInfoRequest{
109+
PostMortemID: postMortemID,
110+
})
111+
if err != nil {
112+
return 0, fmt.Errorf("failed to fetch post-mortem info for %s: %w", postMortemID, err)
113+
}
114+
return info.Meta.Revision, nil
115+
}
116+
117+
// readPostMortemMarkdownFile loads Markdown bytes without trimming. Path "-"
118+
// reads the injectable stdinReader so tests never touch the real stdin and
119+
// absent flags never block on an empty pipe.
120+
func readPostMortemMarkdownFile(path string) (string, error) {
121+
path = strings.TrimSpace(path)
122+
if path == "" {
123+
return "", fmt.Errorf("--markdown-file is required")
124+
}
125+
126+
var (
127+
b []byte
128+
err error
129+
)
130+
if path == "-" {
131+
b, err = io.ReadAll(stdinReader)
132+
if err != nil {
133+
return "", fmt.Errorf("failed to read markdown from stdin: %w", err)
134+
}
135+
} else {
136+
b, err = os.ReadFile(path)
137+
if err != nil {
138+
return "", fmt.Errorf("failed to read markdown file: %w", err)
139+
}
140+
}
141+
if len(b) == 0 {
142+
return "", fmt.Errorf("markdown content must not be empty")
143+
}
144+
return string(b), nil
145+
}

0 commit comments

Comments
 (0)