Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const (
FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)"
FlagFlagDescription = "Default feature flag key"
JSONFlagDescription = "Output JSON format (shorthand for --output json)"
OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)"
OutputFlagDescription = "Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)"
PortFlagDescription = "Port for the dev server to run on"
ProjectFlagDescription = "Default project key"
SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied."
Expand Down
4 changes: 2 additions & 2 deletions cmd/config/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Supported settings:
- `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint
- `environment`: Default environment key
- `flag`: Default feature flag key
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
- `output`: Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)
- `port`: Port for the dev server to run on
- `project`: Default project key
- `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied.
Expand All @@ -29,4 +29,4 @@ Global Flags:
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
--fields strings Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)
--json Output JSON format (shorthand for --output json)
-o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext")
-o, --output string Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise) (default "plaintext")
23 changes: 23 additions & 0 deletions cmd/flags/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,29 @@ func TestArchive(t *testing.T) {
assert.Contains(t, string(output), "test-flag")
})

t.Run("succeeds with markdown output", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "markdown",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Contains(t, string(output), "Successfully updated")
assert.Contains(t, string(output), "## test-flag")
assert.Contains(t, string(output), "- **Kind:** boolean")
})

t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) {
args := []string{
"flags", "archive",
Expand Down
49 changes: 49 additions & 0 deletions cmd/flags/toggle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,31 @@ func TestToggleOn(t *testing.T) {
assert.Contains(t, string(output), "test-flag")
})

t.Run("succeeds with markdown output", func(t *testing.T) {
args := []string{
"flags", "toggle-on",
"--access-token", "abcd1234",
"--environment", "test-env",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "markdown",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Contains(t, string(output), "Successfully updated")
assert.Contains(t, string(output), "## test-flag")
assert.Contains(t, string(output), "- **Kind:** boolean")
assert.Contains(t, string(output), "- **Temporary:** yes")
})

t.Run("returns error with missing required flags", func(t *testing.T) {
args := []string{
"flags", "toggle-on",
Expand Down Expand Up @@ -332,6 +357,30 @@ func TestToggleOff(t *testing.T) {
assert.Contains(t, string(output), "test-flag")
})

t.Run("succeeds with markdown output", func(t *testing.T) {
args := []string{
"flags", "toggle-off",
"--access-token", "abcd1234",
"--environment", "test-env",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "markdown",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Contains(t, string(output), "Successfully updated")
assert.Contains(t, string(output), "## test-flag")
assert.Contains(t, string(output), "- **Kind:** boolean")
})

t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) {
args := []string{
"flags", "toggle-off",
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestUpdate(t *testing.T) {
t.Run("with an invalid output flag", func(t *testing.T) {
_, _, err = c.Update([]string{"output", "invalid"})

assert.EqualError(t, err, "output is invalid. Use 'json' or 'plaintext'")
assert.EqualError(t, err, "output is invalid. Use 'json', 'plaintext', or 'markdown'")
})

t.Run("with an invalid analytics-opt-out flag", func(t *testing.T) {
Expand Down
248 changes: 248 additions & 0 deletions internal/output/markdown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package output

import (
"fmt"
"sort"
"strings"
)

// MarkdownTableOutput formats a slice of resources as a GitHub-flavored markdown table.
func MarkdownTableOutput(items []resource, cols []ColumnDef) string {
headers := make([]string, len(cols))
separators := make([]string, len(cols))
for i, col := range cols {
headers[i] = col.Header
separators[i] = "---"
}

var sb strings.Builder
sb.WriteString("| ")
sb.WriteString(strings.Join(headers, " | "))
sb.WriteString(" |\n| ")
sb.WriteString(strings.Join(separators, " | "))
sb.WriteString(" |")
Comment on lines +18 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any value to us having a utility to build this out? Just curious, not asking for a change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right now, just dont see the need for a dependency for something im not sure will change often yet - but ill keep an eye out for something suitable!


for _, item := range items {
vals := make([]string, len(cols))
for i, col := range cols {
vals[i] = escapeMDPipe(colValue(item, col))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need error handling here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a good idea - deferring to a future pr

}
sb.WriteString("\n| ")
sb.WriteString(strings.Join(vals, " | "))
sb.WriteString(" |")
}

return sb.String()
}

// MarkdownKeyValueOutput formats a single resource as a markdown bullet list of key-value pairs.
func MarkdownKeyValueOutput(r resource, cols []ColumnDef) string {
lines := make([]string, 0, len(cols))
for _, col := range cols {
val := colValue(r, col)
lines = append(lines, fmt.Sprintf("- **%s:** %s", col.Header, val))
}
return strings.Join(lines, "\n")
}

// MarkdownSingularOutput renders a single resource in markdown with a heading and metadata.
// For flags it produces a rich view with environment table; for other resources it uses
// the column registry or a generic fallback.
func MarkdownSingularOutput(r resource, resourceName string) string {
if resourceName == "flags" {
return markdownFlagOutput(r)
}

heading := markdownHeading(r)
if cols := GetSingularColumns(resourceName); cols != nil {
return heading + "\n\n" + MarkdownKeyValueOutput(r, cols)
}
return heading
}

// MarkdownMultipleOutput renders a list of resources as a markdown table (if columns are
// registered) or a bullet list.
func MarkdownMultipleOutput(items []resource, resourceName string) string {
if cols := GetListColumns(resourceName); cols != nil {
return MarkdownTableOutput(items, cols)
}

lines := make([]string, 0, len(items))
for _, item := range items {
lines = append(lines, fmt.Sprintf("- %s", SingularPlaintextOutputFn(item)))
}
return strings.Join(lines, "\n")
}

func markdownFlagOutput(r resource) string {
var sb strings.Builder

key := defaultFormat(r["key"])
sb.WriteString("## ")
sb.WriteString(key)

if desc, ok := r["description"]; ok && desc != nil && fmt.Sprint(desc) != "" {
sb.WriteString("\n\n")
sb.WriteString(fmt.Sprint(desc))
}

envTable := markdownEnvTable(r)
if envTable != "" {
sb.WriteString("\n\n")
sb.WriteString(envTable)
}

if meta := markdownFlagMetadata(r); meta != "" {
sb.WriteString("\n\n")
sb.WriteString(meta)
}

return sb.String()
}
Comment thread
cursor[bot] marked this conversation as resolved.

func markdownEnvTable(r resource) string {
envMap, ok := r["environments"].(map[string]interface{})
if !ok || len(envMap) == 0 {
return ""
}

variations := extractVariations(r)

keys := make([]string, 0, len(envMap))
for k := range envMap {
keys = append(keys, k)
}
sort.Strings(keys)

var sb strings.Builder
sb.WriteString("| Environment | Status | Fallthrough | Rules |\n")
sb.WriteString("| --- | --- | --- | --- |")

for _, envKey := range keys {
envData, ok := envMap[envKey].(map[string]interface{})
if !ok {
continue
}

status := "OFF"
if on, ok := envData["on"].(bool); ok && on {
status = "ON"
}

fallthrough_ := resolveFallthrough(envData, variations)

rulesCount := 0
if rules, ok := envData["rules"].([]interface{}); ok {
rulesCount = len(rules)
}

sb.WriteString(fmt.Sprintf("\n| %s | %s | %s | %d |",
escapeMDPipe(envKey), status, escapeMDPipe(fallthrough_), rulesCount))
}

return sb.String()
}

func markdownFlagMetadata(r resource) string {
var lines []string

if kind := r["kind"]; kind != nil {
lines = append(lines, fmt.Sprintf("- **Kind:** %s", kind))
}
if temp, ok := r["temporary"].(bool); ok {
lines = append(lines, fmt.Sprintf("- **Temporary:** %s", boolYesNo(temp)))
}
if tags, ok := r["tags"].([]interface{}); ok && len(tags) > 0 {
strs := make([]string, len(tags))
for i, t := range tags {
strs[i] = fmt.Sprint(t)
}
lines = append(lines, fmt.Sprintf("- **Tags:** %s", strings.Join(strs, ", ")))
}
if maintainer := extractMaintainer(r); maintainer != "" {
lines = append(lines, fmt.Sprintf("- **Maintainer:** %s", maintainer))
}

return strings.Join(lines, "\n")
}

func extractVariations(r resource) []variation {
raw, ok := r["variations"].([]interface{})
if !ok {
return nil
}
vars := make([]variation, 0, len(raw))
for _, v := range raw {
m, ok := v.(map[string]interface{})
if !ok {
continue
}
name := ""
if n, ok := m["name"].(string); ok {
name = n
}
vars = append(vars, variation{
Name: name,
Value: m["value"],
})
}
return vars
}

type variation struct {
Name string
Value interface{}
}

func resolveFallthrough(envData map[string]interface{}, variations []variation) string {
ft, ok := envData["fallthrough"].(map[string]interface{})
if !ok {
return ""
}
varIdx, ok := ft["variation"].(float64)
if !ok {
return ""
}
idx := int(varIdx)
if idx < 0 || idx >= len(variations) {
return fmt.Sprintf("variation %d", idx)
}
v := variations[idx]
if v.Name != "" {
return fmt.Sprintf("%s (%v)", v.Name, v.Value)
}
return fmt.Sprintf("%v", v.Value)
}

func extractMaintainer(r resource) string {
m, ok := r["_maintainer"].(map[string]interface{})
if !ok {
return ""
}
if name, ok := m["name"].(string); ok && name != "" {
return name
}
if email, ok := m["email"].(string); ok && email != "" {
return email
}
return ""
}

func markdownHeading(r resource) string {
key := r["key"]
name := r["name"]
switch {
case name != nil && key != nil:
return fmt.Sprintf("## %s (%s)", fmt.Sprint(name), fmt.Sprint(key))
case name != nil:
return fmt.Sprintf("## %s", fmt.Sprint(name))
case key != nil:
return fmt.Sprintf("## %s", fmt.Sprint(key))
default:
return "## (unknown)"
}
}

func escapeMDPipe(s string) string {
return strings.ReplaceAll(s, "|", "\\|")
}
Loading
Loading