Skip to content

Commit d19dd81

Browse files
committed
Merge origin/main into feat/cli-full-coverage
Reconcile the full-OpenAPI-coverage feature with main's #26 (incident 6-char short-id resolution in detail/get + list --nums) and #27 (shell tab-completion + install.sh auto-setup). incident.go conflicts resolved to main's design: - list: keep --nums (#26) plus the eval's "--since→--until window < 31d" help text. - get: adopt #26's resolveIncidentArg→List-by-incident_ids body; the full-coverage branch's /incident/info get is superseded by #26's new `detail` command (which carries the no-window Info path). All 7 incident short-id tests pass. postmortem.go / status_page.go (deleted by the coverage branch in favor of generated commands) kept deleted; #27's registerEnumFlag additions to them drop with the files — generated post-mortem/status-page commands cover those ops. completion.go + install.sh + the curated commands' registerEnumFlag calls retained.
2 parents e05257d + 7785aad commit d19dd81

11 files changed

Lines changed: 453 additions & 24 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ flashduty config set base_url URL # Override API endpoint
126126

127127
| Flag | Description |
128128
|------|-------------|
129-
| `--json` | Output as JSON instead of table |
129+
| `--output-format` | Output format: `table` (default), `json`, or `toon` (compact, fewer tokens) |
130+
| `--json` | Output as JSON (alias for `--output-format json`) |
130131
| `--no-trunc` | Do not truncate long fields in table output |
131132
| `--base-url` | Override the API base URL |
132133

@@ -272,12 +273,18 @@ inc_def456 High memory usage Warning Processing Staging 2026
272273
Showing 2 results (page 1, total 2).
273274
```
274275

275-
**JSON (`--json`):** Machine-parseable, full data, no truncation.
276+
**JSON (`--json` / `--output-format json`):** Machine-parseable, full data, no truncation.
276277

277278
```bash
278279
flashduty incident list --json | jq '.[].title'
279280
```
280281

282+
**TOON (`--output-format toon`):** Token-Oriented Object Notation — full data, no truncation, but drops the per-row repeated keys that JSON emits for uniform arrays, so list output costs materially fewer tokens. Preferred for LLM/agent consumption. Not directly `jq`-able; use `--json` when you need to pipe into `jq`.
283+
284+
```bash
285+
flashduty incident list --output-format toon
286+
```
287+
281288
**No truncation (`--no-trunc`):** Table with full field content.
282289

283290
---

install.sh

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,122 @@ resolve_version() {
109109
echo "${version}"
110110
}
111111

112+
# --- shell completion (best-effort, non-intrusive) ---
113+
114+
# The user's interactive shell decides which completion script we need —
115+
# completion varies by shell, not by OS/arch. Empty when it's not one we support.
116+
detect_shell() {
117+
case "$(basename "${SHELL:-}" 2>/dev/null)" in
118+
bash) echo "bash" ;;
119+
zsh) echo "zsh" ;;
120+
fish) echo "fish" ;;
121+
*) echo "" ;;
122+
esac
123+
}
124+
125+
# Emit the completion script for the shell named in $1. Cobra bakes the root
126+
# command name "flashduty" into the script (#compdef / complete -c / function
127+
# names); when installed under a different name, rewrite every occurrence so the
128+
# completion binds to the actual command (the runtime dispatch line already uses
129+
# the typed command word, so it needs no rewrite). The `|` sed delimiter is safe
130+
# because a binary name can't contain it, and the rewrite is a no-op for the
131+
# default "flashduty".
132+
gen_completion() {
133+
"${BIN}" completion "$1" | sed "s|flashduty|${INSTALLED_NAME}|g"
134+
}
135+
136+
# Install completion for the current shell into a directory the shell already
137+
# auto-loads, without ever editing the user's rc files. zsh has no guaranteed
138+
# writable fpath dir, so it only succeeds when a standard site-functions dir is
139+
# already writable (e.g. a Homebrew install); otherwise we point at the binary's
140+
# own per-shell setup instructions.
141+
setup_completion() {
142+
[ "${OS}" = "Windows" ] && return 0
143+
sh_name=$(detect_shell)
144+
[ -z "${sh_name}" ] && return 0
145+
"${BIN}" completion "${sh_name}" >/dev/null 2>&1 || return 0
146+
147+
case "${sh_name}" in
148+
fish)
149+
dir="${XDG_CONFIG_HOME:-${HOME}/.config}/fish/completions"
150+
mkdir -p "${dir}" 2>/dev/null || true
151+
if [ -w "${dir}" ]; then
152+
gen_completion fish > "${dir}/${INSTALLED_NAME}.fish" && {
153+
info "Installed fish completion to ${dir}/${INSTALLED_NAME}.fish (restart fish to load)"
154+
return 0
155+
}
156+
fi
157+
;;
158+
bash)
159+
dir="${XDG_DATA_HOME:-${HOME}/.local/share}/bash-completion/completions"
160+
mkdir -p "${dir}" 2>/dev/null || true
161+
if [ -w "${dir}" ]; then
162+
gen_completion bash > "${dir}/${INSTALLED_NAME}" && {
163+
info "Installed bash completion to ${dir}/${INSTALLED_NAME} (needs the bash-completion package; restart bash to load)"
164+
return 0
165+
}
166+
fi
167+
;;
168+
zsh)
169+
for dir in \
170+
"${HOMEBREW_PREFIX:-/opt/homebrew}/share/zsh/site-functions" \
171+
"/usr/local/share/zsh/site-functions" \
172+
"/usr/share/zsh/site-functions"; do
173+
if [ -d "${dir}" ] && [ -w "${dir}" ]; then
174+
gen_completion zsh > "${dir}/_${INSTALLED_NAME}" && {
175+
info "Installed zsh completion to ${dir}/_${INSTALLED_NAME}"
176+
info " Run 'rm -f ~/.zcompdump*' and restart zsh to load."
177+
return 0
178+
}
179+
fi
180+
done
181+
;;
182+
esac
183+
184+
# Couldn't auto-install into an auto-loaded dir (the common zsh case: no
185+
# writable fpath dir, and we never edit ~/.zshrc). Print the exact,
186+
# copy-pasteable steps so the user can finish setup in one go.
187+
print_manual_completion "${sh_name}"
188+
}
189+
190+
# Print a concrete, copy-pasteable recipe to enable completion for $1, used when
191+
# setup_completion can't drop the script into an auto-loaded directory. Plain
192+
# stdout (no "[flashduty]" prefix) so the commands paste cleanly.
193+
print_manual_completion() {
194+
name="${INSTALLED_NAME}"
195+
info "Shell completion was not auto-installed. To enable it for $1, run:"
196+
case "$1" in
197+
zsh)
198+
cat <<EOF
199+
200+
mkdir -p ~/.zsh/completions
201+
${name} completion zsh > ~/.zsh/completions/_${name}
202+
echo 'fpath=(~/.zsh/completions \$fpath)' >> ~/.zshrc # one-time
203+
rm -f ~/.zcompdump* && exec zsh
204+
205+
EOF
206+
;;
207+
bash)
208+
cat <<EOF
209+
210+
mkdir -p ~/.local/share/bash-completion/completions
211+
${name} completion bash > ~/.local/share/bash-completion/completions/${name}
212+
# requires the bash-completion package; then restart bash
213+
214+
EOF
215+
;;
216+
fish)
217+
cat <<EOF
218+
219+
mkdir -p ~/.config/fish/completions
220+
${name} completion fish > ~/.config/fish/completions/${name}.fish
221+
# then restart fish
222+
223+
EOF
224+
;;
225+
esac
226+
}
227+
112228
# --- main ---
113229

114230
main() {
@@ -201,6 +317,10 @@ main() {
201317
info " export PATH=\"${INSTALL_DIR}:\$PATH\"" ;;
202318
esac
203319

320+
# Best-effort shell completion; never fail the install over it.
321+
BIN="${INSTALL_DIR}/${INSTALLED_NAME}"
322+
setup_completion || true
323+
204324
info "Run '${INSTALLED_NAME} version' to verify"
205325
}
206326

internal/cli/alert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ func newAlertListCmd() *cobra.Command {
100100

101101
cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info")
102102
cmd.Flags().BoolVar(&active, "active", false, "Show active only")
103+
registerEnumFlag(cmd, "severity", severityEnum...)
103104
cmd.Flags().BoolVar(&recovered, "recovered", false, "Show recovered only")
104105
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
105106
cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only")

internal/cli/alert_event.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ func newAlertEventListCmd() *cobra.Command {
8484

8585
cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info (comma-separated)")
8686
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
87+
registerEnumFlag(cmd, "severity", severityEnum...)
8788
cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types")
8889
cmd.Flags().StringVar(&since, "since", "1h", "Start time")
8990
cmd.Flags().StringVar(&until, "until", "now", "End time")

internal/cli/completion.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package cli
2+
3+
import "github.com/spf13/cobra"
4+
5+
// severityEnum is the closed set of incident/alert severities, shared by every
6+
// --severity flag.
7+
var severityEnum = []string{"Critical", "Warning", "Info"}
8+
9+
// registerEnumFlag makes <flag>'s value tab-complete to a fixed set and
10+
// suppresses the default filename completion. The error only fires on an
11+
// unknown flag name (a programmer error), so it is ignored.
12+
func registerEnumFlag(cmd *cobra.Command, flag string, values ...string) {
13+
_ = cmd.RegisterFlagCompletionFunc(flag, func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
14+
return values, cobra.ShellCompDirectiveNoFileComp
15+
})
16+
}

internal/cli/incident.go

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package cli
22

33
import (
44
"bufio"
5+
"errors"
56
"fmt"
67
"io"
78
"os"
9+
"regexp"
810
"strconv"
911
"strings"
1012
"time"
@@ -73,7 +75,7 @@ func pastIncidentColumns() []output.Column {
7375
}
7476

7577
func newIncidentListCmd() *cobra.Command {
76-
var progress, severity, query, since, until string
78+
var progress, severity, query, since, until, nums string
7779
var channelID int64
7880
var limit, page int
7981

@@ -104,6 +106,9 @@ func newIncidentListCmd() *cobra.Command {
104106
if channelID != 0 {
105107
req.ChannelIDs = []int64{channelID}
106108
}
109+
if nums != "" {
110+
req.Nums = parseStringSlice(nums)
111+
}
107112

108113
result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), req)
109114
if err != nil {
@@ -117,8 +122,11 @@ func newIncidentListCmd() *cobra.Command {
117122

118123
cmd.Flags().StringVar(&progress, "progress", "", "Filter: Triggered,Processing,Closed")
119124
cmd.Flags().StringVar(&severity, "severity", "", "Filter: Critical,Warning,Info")
125+
registerEnumFlag(cmd, "progress", "Triggered", "Processing", "Closed")
126+
registerEnumFlag(cmd, "severity", severityEnum...)
120127
cmd.Flags().Int64Var(&channelID, "channel", 0, "Filter by channel ID")
121128
cmd.Flags().StringVar(&query, "query", "", "Free-text search across title/labels/content (also resolves a 24-char incident ID or 6-char incident num to a direct lookup)")
129+
cmd.Flags().StringVar(&nums, "nums", "", "Comma-separated short incident ids (num, the 6-char id shown in the UI) to filter by")
122130
cmd.Flags().StringVar(&since, "since", "24h", "Start time (duration, date, datetime, or unix timestamp; --since→--until window must be < 31 days)")
123131
cmd.Flags().StringVar(&until, "until", "now", "End time")
124132
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")
@@ -127,6 +135,62 @@ func newIncidentListCmd() *cobra.Command {
127135
return cmd
128136
}
129137

138+
// shortIDResolveDays bounds how far back a 6-char short id is resolved.
139+
// /incident/list is the only endpoint that accepts a num, and the backend caps
140+
// its query span at ~30 days, so older incidents can only be looked up by their
141+
// full 24-char id.
142+
const shortIDResolveDays = 30
143+
144+
var reShortIncidentID = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)
145+
146+
// resolveIncidentArg maps a user-supplied incident argument to a full 24-char
147+
// incident id. A full id — or any value that isn't a 6-char short id — passes
148+
// through unchanged, preserving existing behavior. A 6-char short id ("num", as
149+
// shown in the UI) is resolved against the last shortIDResolveDays days via
150+
// /incident/list. A unique hit returns the full id; multiple hits return the
151+
// candidates (the short id is non-unique by design) so the caller can surface
152+
// them; no hit returns a descriptive error.
153+
func resolveIncidentArg(ctx *RunContext, arg string) (fullID string, candidates []flashduty.IncidentInfo, err error) {
154+
if !reShortIncidentID.MatchString(arg) {
155+
return arg, nil, nil
156+
}
157+
158+
end := time.Now().Unix()
159+
start := end - int64(shortIDResolveDays)*24*60*60
160+
res, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), &flashduty.ListIncidentsRequest{
161+
Nums: []string{strings.ToUpper(arg)},
162+
StartTime: start,
163+
EndTime: end,
164+
})
165+
if err != nil {
166+
return "", nil, err
167+
}
168+
169+
switch len(res.Items) {
170+
case 0:
171+
return "", nil, fmt.Errorf(
172+
"no incident with short id %q in the last %d days; older incidents must be queried by their full 24-char id",
173+
arg, shortIDResolveDays)
174+
case 1:
175+
return res.Items[0].IncidentID, nil, nil
176+
default:
177+
return "", res.Items, nil
178+
}
179+
}
180+
181+
// ambiguousShortIDError reports a short id that resolved to more than one
182+
// incident, listing each candidate's full id so the caller can disambiguate.
183+
func ambiguousShortIDError(shortID string, candidates []flashduty.IncidentInfo) error {
184+
var b strings.Builder
185+
_, _ = fmt.Fprintf(&b, "short id %q matches %d incidents in the last %d days — re-run with one of these full ids:",
186+
shortID, len(candidates), shortIDResolveDays)
187+
for _, c := range candidates {
188+
_, _ = fmt.Fprintf(&b, "\n %s %-8s %-10s %s %s",
189+
c.IncidentID, orDash(c.IncidentSeverity), orDash(c.Progress), output.FormatTime(c.StartTime), c.Title)
190+
}
191+
return errors.New(b.String())
192+
}
193+
130194
func newIncidentGetCmd() *cobra.Command {
131195
return &cobra.Command{
132196
Use: "get <id> [<id2> ...]",
@@ -135,36 +199,37 @@ func newIncidentGetCmd() *cobra.Command {
135199
Args: requireArgs("incident_id"),
136200
RunE: func(cmd *cobra.Command, args []string) error {
137201
return runCommand(cmd, args, func(ctx *RunContext) error {
138-
// Fetch each incident by ID via /incident/info: it works for
139-
// any id with no time window. /incident/list cannot serve a
140-
// plain get-by-id — it mandates a start/end window and caps it
141-
// at 31 days, so it 400s ("StartTime is a required field") when
142-
// queried by id alone.
143-
items := make([]flashduty.IncidentInfo, 0, len(ctx.Args))
144-
for _, id := range ctx.Args {
145-
info, _, err := ctx.Client.Incidents.Info(cmdContext(ctx.Cmd), &flashduty.IncidentInfoRequest{
146-
IncidentID: id,
147-
})
202+
ids := make([]string, 0, len(ctx.Args))
203+
for _, a := range ctx.Args {
204+
fullID, candidates, err := resolveIncidentArg(ctx, a)
148205
if err != nil {
149-
return fmt.Errorf("get incident %s: %w", id, err)
206+
return err
150207
}
151-
if info != nil {
152-
items = append(items, *info)
208+
if len(candidates) > 0 {
209+
return ambiguousShortIDError(a, candidates)
153210
}
211+
ids = append(ids, fullID)
212+
}
213+
214+
result, _, err := ctx.Client.Incidents.List(cmdContext(ctx.Cmd), &flashduty.ListIncidentsRequest{
215+
IncidentIDs: ids,
216+
})
217+
if err != nil {
218+
return err
154219
}
155220

156221
if ctx.Structured() {
157-
return ctx.Printer.Print(items, nil)
222+
return ctx.Printer.Print(result.Items, nil)
158223
}
159224

160225
// Single incident: vertical detail view
161-
if len(items) == 1 {
162-
printIncidentDetail(ctx.Writer, items[0])
226+
if len(ctx.Args) == 1 && len(result.Items) == 1 {
227+
printIncidentDetail(ctx.Writer, result.Items[0])
163228
return nil
164229
}
165230

166231
// Multiple: table
167-
return ctx.Printer.Print(items, incidentColumns())
232+
return ctx.Printer.Print(result.Items, incidentColumns())
168233
})
169234
},
170235
}
@@ -277,6 +342,7 @@ func newIncidentCreateCmd() *cobra.Command {
277342
cmd.Flags().StringVar(&title, "title", "", "Incident title (required, 3-200 chars)")
278343
cmd.Flags().StringVar(&severity, "severity", "", "Severity: Critical, Warning, Info (required)")
279344
cmd.Flags().Int64Var(&channelID, "channel", 0, "Channel ID")
345+
registerEnumFlag(cmd, "severity", severityEnum...)
280346
cmd.Flags().StringVar(&description, "description", "", "Description (max 6144 chars)")
281347
cmd.Flags().IntSliceVar(&assign, "assign", nil, "Person IDs to assign (use 'flashduty member list' to look up IDs)")
282348

@@ -368,6 +434,7 @@ func newIncidentUpdateCmd() *cobra.Command {
368434
cmd.Flags().StringVar(&description, "description", "", "New description")
369435
cmd.Flags().StringVar(&severity, "severity", "", "New severity: Critical, Warning, Info")
370436
cmd.Flags().StringArrayVar(&fieldFlags, "field", nil, "Custom field: key=value (repeatable)")
437+
registerEnumFlag(cmd, "severity", severityEnum...)
371438

372439
return cmd
373440
}
@@ -1351,8 +1418,16 @@ func newIncidentDetailCmd() *cobra.Command {
13511418
Args: requireArgs("incident_id"),
13521419
RunE: func(cmd *cobra.Command, args []string) error {
13531420
return runCommand(cmd, args, func(ctx *RunContext) error {
1421+
fullID, candidates, err := resolveIncidentArg(ctx, ctx.Args[0])
1422+
if err != nil {
1423+
return err
1424+
}
1425+
if len(candidates) > 0 {
1426+
return ambiguousShortIDError(ctx.Args[0], candidates)
1427+
}
1428+
13541429
result, _, err := ctx.Client.Incidents.Info(cmdContext(ctx.Cmd), &flashduty.IncidentInfoRequest{
1355-
IncidentID: ctx.Args[0],
1430+
IncidentID: fullID,
13561431
})
13571432
if err != nil {
13581433
return err

0 commit comments

Comments
 (0)