feat: add docgen skill generator and make generate-skill target (#131)

* feat: add docgen skill generator and make generate-skill target

Add cmd/docgen/ and internal/docgen/ that introspect the Cobra command
tree and generate per-topic markdown rule files under skills/bkt/rules/.

This is PR 2 of 4 in the auto-generate skill series:
1. Add detailed docstrings to all commands (merged)
2. (this) Generator core + make generate-skill
3. Commit generated rules/*.md, remove references/commands.md, update SKILL.md
4. Pre-commit hook + CI validation for staleness detection

Key features:
- One rule file per command group, standalones grouped into other.md
- Subcommand tables with key flags and platform badges *(DC)* / *(Cloud)*
- Recursive rendering of nested command groups (e.g. pr task, branch protect)
- Inherited flags section for commands with root persistent flags
- Auto-generated header for stale file cleanup on regeneration
- SKILL.md References section updated with start/end sentinel markers
- Handles trailing slash, custom output dirs, and preview outside skill dir

24 tests covering: group files, standalone files, hidden exclusion, nested
subcommands, inherited flags, platform tags, stale cleanup, SKILL.md
updates (footer preservation, trailing slash, custom dir), examples for
groups and nested groups, Long fallback to Short.

* fix: resolve golangci-lint errcheck and staticcheck findings

- Check f.Close() return value in writeFile and writeStandalonesFile
- Use fmt.Fprintf instead of WriteString(fmt.Sprintf(...))
- Check os.WriteFile and os.MkdirAll return values in tests
This commit is contained in:
Eran Krakovsky
2026-04-07 11:46:18 +03:00
committed by GitHub
parent cb74cfbd0f
commit 614141c44c
4 changed files with 1188 additions and 1 deletions
+4 -1
View File
@@ -23,7 +23,7 @@ LDFLAGS := -s -w \
-X github.com/avivsinai/bitbucket-cli/internal/build.commitFromLdflags=$(COMMIT) \
-X github.com/avivsinai/bitbucket-cli/internal/build.dateFromLdflags=$(BUILD_DATE)
.PHONY: build fmt lint test tidy sbom release snapshot clean check-skills release-local
.PHONY: build fmt lint test tidy sbom release snapshot clean check-skills release-local generate-skill
build: $(BIN_DIR)/bkt
@@ -71,6 +71,9 @@ snapshot:
clean:
rm -rf $(BIN_DIR) dist/
generate-skill:
$(GO) run ./cmd/docgen -o skills/bkt/rules
release:
@test -n "$(RELEASE_VERSION)" || (echo "usage: make release RELEASE_VERSION=X.Y.Z [RELEASE_DATE=YYYY-MM-DD] [RELEASE_SKIP_VERIFY=1] [RELEASE_ALLOW_EMPTY=1] [RELEASE_NO_AUTO_MERGE=1]" && exit 1)
./scripts/release.sh "$(RELEASE_VERSION)" $(if $(RELEASE_DATE),--date $(RELEASE_DATE),) $(if $(RELEASE_SKIP_VERIFY),--skip-verify,) $(if $(RELEASE_ALLOW_EMPTY),--allow-empty,) $(if $(RELEASE_NO_AUTO_MERGE),--no-auto-merge,)
+46
View File
@@ -0,0 +1,46 @@
// Command docgen generates skill rule files from the bkt Cobra command tree.
//
// Usage:
//
// go run ./cmd/docgen [-o skills/bkt/rules]
package main
import (
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/avivsinai/bitbucket-cli/internal/docgen"
"github.com/avivsinai/bitbucket-cli/pkg/cmd/root"
"github.com/avivsinai/bitbucket-cli/pkg/cmdutil"
"github.com/avivsinai/bitbucket-cli/pkg/iostreams"
)
func main() {
outDir := flag.String("o", "skills/bkt/rules", "Output directory for generated rule files")
flag.Parse()
f := &cmdutil.Factory{
ExecutableName: "bkt",
IOStreams: &iostreams.IOStreams{
In: io.NopCloser(strings.NewReader("")),
Out: io.Discard,
ErrOut: io.Discard,
},
}
rootCmd, err := root.NewCmdRoot(f)
if err != nil {
fmt.Fprintf(os.Stderr, "build command tree: %v\n", err)
os.Exit(1)
}
if err := docgen.GenerateAll(rootCmd, "bkt", *outDir); err != nil {
fmt.Fprintf(os.Stderr, "generate: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Generated skill rules in %s\n", *outDir)
}
+483
View File
@@ -0,0 +1,483 @@
// Package docgen generates skill rule files from a Cobra command tree.
package docgen
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// GenerateAll writes one markdown rule file per top-level command group into
// outDir. Standalone commands (no subcommands) are grouped into other.md.
func GenerateAll(root *cobra.Command, binName, outDir string) error {
outDir = filepath.Clean(outDir)
if err := os.MkdirAll(outDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
// Remove stale .md files from a previous run so renamed/deleted
// commands don't linger in the output directory.
if err := removeMarkdownFiles(outDir); err != nil {
return err
}
groups := collectGroups(root)
var standalones []*cobra.Command
for _, cmd := range groups {
if !cmd.HasSubCommands() {
standalones = append(standalones, cmd)
continue
}
path := filepath.Join(outDir, cmd.Name()+".md")
if err := writeFile(path, binName, cmd); err != nil {
return err
}
}
if len(standalones) > 0 {
path := filepath.Join(outDir, "other.md")
if err := writeStandalonesFile(path, binName, standalones); err != nil {
return err
}
}
// Build list of generated rule files for SKILL.md update
var ruleFiles []ruleEntry
for _, cmd := range groups {
if cmd.HasSubCommands() {
desc := stripPlatformSuffix(cmd.Short)
if tag := platformTag(cmd); tag != "" {
desc += " " + tag
}
ruleFiles = append(ruleFiles, ruleEntry{
File: cmd.Name() + ".md",
Label: cmd.Name(),
Desc: desc,
})
}
}
if len(standalones) > 0 {
var names []string
for _, cmd := range standalones {
names = append(names, cmd.Name())
}
ruleFiles = append(ruleFiles, ruleEntry{
File: "other.md",
Label: "other",
Desc: strings.Join(names, ", "),
})
}
// Update SKILL.md references section (skip if SKILL.md doesn't exist,
// e.g. when -o points outside the skill directory for preview).
skillPath := filepath.Join(filepath.Dir(outDir), "SKILL.md")
rulesDir := filepath.Base(outDir) // e.g. "rules" or "generated"
if _, err := os.Stat(skillPath); err == nil {
if err := updateSkillReferences(skillPath, ruleFiles, rulesDir); err != nil {
return err
}
}
return nil
}
type ruleEntry struct {
File string
Label string
Desc string
}
// updateSkillReferences replaces the ## References section in SKILL.md with
// links to the generated rule files.
func updateSkillReferences(skillPath string, rules []ruleEntry, rulesDir string) error {
data, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("read %s: %w", skillPath, err)
}
content := string(data)
const startMarker = "<!-- auto-generated by cmd/docgen — do not edit below this line -->"
const endMarker = "<!-- end auto-generated -->"
// Build the new generated block
var gen strings.Builder
gen.WriteString(startMarker + "\n\n")
for _, r := range rules {
fmt.Fprintf(&gen, "- [%s](%s/%s) — %s\n", r.Label, rulesDir, r.File, r.Desc)
}
gen.WriteString("\n" + endMarker)
// Try to replace an existing generated block (between start and end markers)
startIdx := strings.Index(content, startMarker)
endIdx := strings.Index(content, endMarker)
if startIdx >= 0 && endIdx >= startIdx {
content = content[:startIdx] + gen.String() + content[endIdx+len(endMarker):]
return os.WriteFile(skillPath, []byte(content), 0o644)
}
// No existing generated block — find ## References and replace
// everything from the heading to the next ## heading (or EOF)
const refHeading = "## References"
refIdx := strings.Index(content, refHeading)
if refIdx >= 0 {
// Find the next ## heading after References (if any) to preserve content after it
after := content[refIdx+len(refHeading):]
nextHeading := strings.Index(after, "\n## ")
var tail string
if nextHeading >= 0 {
tail = after[nextHeading+1:] // keep the \n before ##
}
content = content[:refIdx] + refHeading + "\n\n" + gen.String() + "\n" + tail
} else {
// No References section — append one
content = strings.TrimRight(content, "\n") + "\n\n" + refHeading + "\n\n" + gen.String() + "\n"
}
return os.WriteFile(skillPath, []byte(content), 0o644)
}
const generatedHeader = "<!-- auto-generated by cmd/docgen"
// removeMarkdownFiles deletes .md files in dir that were previously generated
// by docgen (identified by the auto-generated header comment). Hand-maintained
// files are left untouched.
func removeMarkdownFiles(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read output dir: %w", err)
}
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".md" {
continue
}
path := filepath.Join(dir, e.Name())
head, err := os.ReadFile(path)
if err != nil {
continue
}
if !strings.HasPrefix(string(head), generatedHeader) {
continue
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove stale file %s: %w", e.Name(), err)
}
}
return nil
}
// collectGroups returns the visible top-level commands sorted by name.
func collectGroups(root *cobra.Command) []*cobra.Command {
var cmds []*cobra.Command
for _, cmd := range root.Commands() {
if cmd.Hidden || cmd.Name() == "help" || cmd.Name() == "completion" {
continue
}
cmds = append(cmds, cmd)
}
sort.Slice(cmds, func(i, j int) bool {
return cmds[i].Name() < cmds[j].Name()
})
return cmds
}
func writeFile(path, binName string, cmd *cobra.Command) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
writeGroupFile(f, binName, cmd, true)
return f.Close()
}
func writeStandalonesFile(path, binName string, cmds []*cobra.Command) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
fmt.Fprintln(f, "<!-- auto-generated by cmd/docgen — do not edit -->")
fmt.Fprintln(f)
fmt.Fprintln(f, "# Other Commands")
fmt.Fprintln(f)
for i, cmd := range cmds {
if i > 0 {
fmt.Fprintln(f, "---")
fmt.Fprintln(f)
}
writeGroupFile(f, binName, cmd, false)
}
return f.Close()
}
// writeGroupFile writes a single command group (with its subcommands) to w.
// When emitHeader is true, the auto-generated comment is written at the top.
func writeGroupFile(w io.Writer, binName string, cmd *cobra.Command, emitHeader bool) {
fullName := binName + " " + cmd.Name()
if emitHeader {
fmt.Fprintln(w, "<!-- auto-generated by cmd/docgen — do not edit -->")
fmt.Fprintln(w)
}
// Title
fmt.Fprintf(w, "# %s\n\n", fullName)
// Description
desc := cmd.Long
if desc == "" {
desc = cmd.Short
}
if desc != "" {
fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(desc))
}
// Usage
if cmd.HasSubCommands() {
fmt.Fprintf(w, "```\n%s <command> [flags]\n```\n\n", fullName)
} else {
useLine := formatUseLine(binName, cmd)
fmt.Fprintf(w, "## Usage\n\n```\n%s\n```\n\n", useLine)
}
// Leaf command — show flags and examples, then return
if !cmd.HasSubCommands() {
writeFlags(w, cmd)
writeExamples(w, cmd)
return
}
// Group command — show examples before subcommand table if present
writeExamples(w, cmd)
// Subcommand table
subs := visibleSubcommands(cmd)
if len(subs) > 0 {
fmt.Fprintln(w, "## Subcommands")
fmt.Fprintln(w)
fmt.Fprintln(w, "| Subcommand | Description | Key Flags |")
fmt.Fprintln(w, "|---|---|---|")
for _, sub := range subs {
anchor := strings.ReplaceAll(fullName+"-"+sub.Name(), " ", "-")
keyFlags := topFlags(sub, 4)
desc := stripPlatformSuffix(sub.Short)
if tag := platformTag(sub); tag != "" {
desc += " " + tag
}
fmt.Fprintf(w, "| [%s](#%s) | %s | %s |\n",
sub.Name(), anchor, desc, keyFlags)
}
fmt.Fprintln(w)
}
// Per-subcommand sections
for _, sub := range subs {
writeSubcommandSection(w, fullName, sub)
}
}
// writeSubcommandSection renders a subcommand. If the subcommand itself has
// children (e.g., "pr task" -> "pr task list", "pr task create"), it recurses.
func writeSubcommandSection(w io.Writer, parentPath string, sub *cobra.Command) {
subFull := parentPath + " " + sub.Name()
fmt.Fprintf(w, "## %s\n\n", subFull)
subDesc := sub.Long
if subDesc == "" {
subDesc = sub.Short
}
if subDesc != "" {
fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(subDesc))
}
// Aliases
if len(sub.Aliases) > 0 {
fmt.Fprintf(w, "**Alias:** `%s`\n\n", strings.Join(sub.Aliases, "`, `"))
}
// If this subcommand has its own children, render a mini subcommand table
// and recurse into each child instead of showing usage/flags for the parent.
nested := visibleSubcommands(sub)
if len(nested) > 0 {
fmt.Fprintf(w, "```\n%s <command> [flags]\n```\n\n", subFull)
writeExamples(w, sub)
fmt.Fprintln(w, "| Subcommand | Description |")
fmt.Fprintln(w, "|---|---|")
for _, child := range nested {
fmt.Fprintf(w, "| %s | %s |\n", child.Name(), child.Short)
}
fmt.Fprintln(w)
for _, child := range nested {
writeSubcommandSection(w, subFull, child)
}
return
}
// Leaf subcommand — show usage, flags, examples
subUseLine := formatUseLine(parentPath, sub)
fmt.Fprintf(w, "### Usage\n\n```\n%s\n```\n\n", subUseLine)
writeFlags(w, sub)
writeExamples(w, sub)
}
func writeFlags(w io.Writer, cmd *cobra.Command) {
local := collectFlags(cmd)
inherited := collectInheritedFlags(cmd)
if len(local) == 0 && len(inherited) == 0 {
return
}
if len(local) > 0 {
fmt.Fprintln(w, "### Flags")
fmt.Fprintln(w)
writeFlagTable(w, local)
}
if len(inherited) > 0 {
fmt.Fprintln(w, "### Inherited Flags")
fmt.Fprintln(w)
writeFlagTable(w, inherited)
}
}
func writeFlagTable(w io.Writer, flags []flagInfo) {
fmt.Fprintln(w, "| Flag | Short | Description |")
fmt.Fprintln(w, "|---|---|---|")
for _, fl := range flags {
short := ""
if fl.Shorthand != "" {
short = "`-" + fl.Shorthand + "`"
}
fmt.Fprintf(w, "| `--%s` | %s | %s |\n", fl.Name, short, fl.Usage)
}
fmt.Fprintln(w)
}
func writeExamples(w io.Writer, cmd *cobra.Command) {
if cmd.Example == "" {
return
}
fmt.Fprintln(w, "### Examples")
fmt.Fprintln(w)
fmt.Fprintln(w, "```bash")
fmt.Fprintln(w, strings.TrimSpace(cmd.Example))
fmt.Fprintln(w, "```")
fmt.Fprintln(w)
}
// formatUseLine builds a usage string like "bkt pr create [flags]".
// cmd.Use contains the command name plus args (e.g., "create" or "api <path>").
// parentPath is the already-qualified prefix (e.g., "bkt pr" or "bkt").
func formatUseLine(parentPath string, cmd *cobra.Command) string {
// cmd.Use is "name <args>" — extract args portion after the command name
parts := strings.SplitN(cmd.Use, " ", 2)
args := ""
if len(parts) > 1 {
args = " " + parts[1]
}
// Append [flags] if the command accepts any flags (local or inherited)
flags := ""
if cmd.HasLocalFlags() || cmd.HasInheritedFlags() {
flags = " [flags]"
}
return parentPath + " " + cmd.Name() + args + flags
}
// stripPlatformSuffix removes trailing "(DC only)" or "(Cloud only)" from a
// Short description so the generated table doesn't duplicate the badge.
func stripPlatformSuffix(s string) string {
s = strings.TrimSuffix(s, " (DC only)")
s = strings.TrimSuffix(s, " (Cloud only)")
return s
}
// platformTag returns a markdown badge if the command is platform-specific.
// It checks for "(DC only)" or "(Cloud only)" markers in the Short field,
// which are the canonical source set in the Cobra command definitions.
func platformTag(cmd *cobra.Command) string {
s := strings.ToLower(cmd.Short)
if strings.Contains(s, "(dc only)") {
return "*(DC)*"
}
if strings.Contains(s, "(cloud only)") {
return "*(Cloud)*"
}
return ""
}
func visibleSubcommands(cmd *cobra.Command) []*cobra.Command {
var subs []*cobra.Command
for _, sub := range cmd.Commands() {
if sub.Hidden || sub.Name() == "help" {
continue
}
subs = append(subs, sub)
}
return subs
}
type flagInfo struct {
Name string
Shorthand string
Usage string
}
func collectFlags(cmd *cobra.Command) []flagInfo {
var flags []flagInfo
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
flags = append(flags, flagInfo{
Name: f.Name,
Shorthand: f.Shorthand,
Usage: f.Usage,
})
})
return flags
}
func collectInheritedFlags(cmd *cobra.Command) []flagInfo {
var flags []flagInfo
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
flags = append(flags, flagInfo{
Name: f.Name,
Shorthand: f.Shorthand,
Usage: f.Usage,
})
})
return flags
}
// topFlags returns the first n flag names formatted for the subcommand table.
func topFlags(cmd *cobra.Command, n int) string {
flags := collectFlags(cmd)
if len(flags) == 0 {
return "—"
}
limit := n
if len(flags) < limit {
limit = len(flags)
}
var names []string
for _, f := range flags[:limit] {
names = append(names, "`--"+f.Name+"`")
}
return strings.Join(names, ", ")
}
+655
View File
@@ -0,0 +1,655 @@
package docgen
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func newTestTree() *cobra.Command {
root := &cobra.Command{
Use: "bkt",
Short: "Bitbucket CLI",
}
// Command group with subcommands
pr := &cobra.Command{
Use: "pr",
Short: "Manage pull requests",
Long: "Work with pull requests on Bitbucket.",
}
prList := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List pull requests",
Long: "List pull requests in a repository, optionally filtered by state.",
Example: ` # List open pull requests
bkt pr list --state OPEN
# List your pull requests
bkt pr list --mine`,
}
prList.Flags().String("state", "OPEN", "Filter by state")
prList.Flags().Bool("mine", false, "Show your PRs")
prList.Flags().Int("limit", 20, "Maximum results")
prCreate := &cobra.Command{
Use: "create",
Short: "Create a pull request",
Long: "Create a new pull request from a source branch to a target branch.",
}
prCreate.Flags().StringP("title", "t", "", "PR title")
prCreate.Flags().String("source", "", "Source branch")
prCreate.Flags().String("target", "", "Target branch")
prCreate.Flags().BoolP("draft", "d", false, "Create as draft")
// Nested subcommand group (pr task -> task list, task create)
prTask := &cobra.Command{
Use: "task",
Short: "Manage PR tasks",
Long: "Create and manage tasks on pull requests.",
}
prTaskList := &cobra.Command{
Use: "list <id>",
Short: "List tasks on a PR",
}
prTaskCreate := &cobra.Command{
Use: "create <id>",
Short: "Create a task on a PR",
}
prTaskCreate.Flags().String("text", "", "Task text")
prTask.AddCommand(prTaskList, prTaskCreate)
// DC-only subcommand
prReaction := &cobra.Command{
Use: "reaction",
Short: "Manage comment reactions (DC only)",
Long: "Add or remove emoji reactions on pull request comments.",
}
// Cloud-only subcommand
prPipeline := &cobra.Command{
Use: "pipeline <id>",
Short: "Show pipeline status for a PR (Cloud only)",
Long: "Show pipeline status for a pull request.",
}
pr.AddCommand(prList, prCreate, prTask, prReaction, prPipeline)
// Root persistent flags (inherited by all commands)
root.PersistentFlags().StringP("context", "c", "", "Active context name")
root.PersistentFlags().Bool("json", false, "Output in JSON format")
// Hidden command (should be excluded)
hidden := &cobra.Command{
Use: "internal-debug",
Short: "Debug internals",
Hidden: true,
}
// Standalone command (no subcommands)
api := &cobra.Command{
Use: "api <path>",
Short: "Make raw API requests",
Long: "Call Bitbucket REST APIs directly.",
Example: ` # Get projects
bkt api /rest/api/1.0/projects`,
}
api.Flags().StringP("method", "X", "", "HTTP method")
root.AddCommand(pr, hidden, api)
return root
}
func TestGenerateGroupFile(t *testing.T) {
root := newTestTree()
pr, _, _ := root.Find([]string{"pr"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", pr, true)
got := buf.String()
// Header
if !strings.Contains(got, "<!-- auto-generated by cmd/docgen") {
t.Error("missing auto-generated header")
}
// Title
if !strings.Contains(got, "# bkt pr") {
t.Error("missing title '# bkt pr'")
}
// Long description
if !strings.Contains(got, "Work with pull requests on Bitbucket.") {
t.Error("missing Long description")
}
// Usage block
if !strings.Contains(got, "bkt pr <command>") {
t.Error("missing usage block")
}
// Subcommand table
if !strings.Contains(got, "| Subcommand |") {
t.Error("missing subcommand table header")
}
if !strings.Contains(got, "[list](#bkt-pr-list)") {
t.Error("missing list subcommand link")
}
if !strings.Contains(got, "[create](#bkt-pr-create)") {
t.Error("missing create subcommand link")
}
// Alias
if !strings.Contains(got, "**Alias:** `ls`") {
t.Error("missing alias for list")
}
// Subcommand sections
if !strings.Contains(got, "## bkt pr list") {
t.Error("missing 'bkt pr list' section")
}
if !strings.Contains(got, "## bkt pr create") {
t.Error("missing 'bkt pr create' section")
}
// Flags table
if !strings.Contains(got, "| Flag |") {
t.Error("missing flags table")
}
if !strings.Contains(got, "`--state`") {
t.Error("missing --state flag")
}
if !strings.Contains(got, "`-t`") {
t.Error("missing -t shorthand")
}
if !strings.Contains(got, "`--draft`") {
t.Error("missing --draft flag")
}
// Example block
if !strings.Contains(got, "bkt pr list --state OPEN") {
t.Error("missing example content")
}
}
func TestGenerateStandaloneFile(t *testing.T) {
root := newTestTree()
api, _, _ := root.Find([]string{"api"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", api, true)
got := buf.String()
if !strings.Contains(got, "# bkt api") {
t.Error("missing title")
}
if !strings.Contains(got, "Call Bitbucket REST APIs directly.") {
t.Error("missing Long description")
}
if !strings.Contains(got, "`--method`") {
t.Error("missing --method flag")
}
if !strings.Contains(got, "bkt api /rest/api/1.0/projects") {
t.Error("missing example")
}
// Standalone should NOT have subcommand table
if strings.Contains(got, "| Subcommand |") {
t.Error("standalone command should not have subcommand table")
}
}
func TestHiddenCommandsExcluded(t *testing.T) {
root := newTestTree()
groups := collectGroups(root)
for _, g := range groups {
if g.Name() == "internal-debug" {
t.Error("hidden command should be excluded")
}
}
}
func TestCollectGroups(t *testing.T) {
root := newTestTree()
groups := collectGroups(root)
names := make(map[string]bool)
for _, g := range groups {
names[g.Name()] = true
}
if !names["pr"] {
t.Error("expected pr group")
}
if !names["api"] {
t.Error("expected api group")
}
if names["internal-debug"] {
t.Error("hidden command should not appear")
}
}
func TestNestedSubcommands(t *testing.T) {
root := newTestTree()
pr, _, _ := root.Find([]string{"pr"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", pr, true)
got := buf.String()
// Parent group "pr task" should appear
if !strings.Contains(got, "## bkt pr task") {
t.Error("missing 'bkt pr task' section")
}
// Nested children should be rendered
if !strings.Contains(got, "## bkt pr task list") {
t.Error("missing nested 'bkt pr task list' section")
}
if !strings.Contains(got, "## bkt pr task create") {
t.Error("missing nested 'bkt pr task create' section")
}
// Nested child flags should appear
if !strings.Contains(got, "`--text`") {
t.Error("missing --text flag from nested task create")
}
}
func TestInheritedFlags(t *testing.T) {
root := newTestTree()
pr, _, _ := root.Find([]string{"pr"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", pr, true)
got := buf.String()
// Inherited flags from root should appear
if !strings.Contains(got, "### Inherited Flags") {
t.Error("missing Inherited Flags section")
}
if !strings.Contains(got, "`--context`") {
t.Error("missing inherited --context flag")
}
if !strings.Contains(got, "`--json`") {
t.Error("missing inherited --json flag")
}
}
func TestPlatformTag(t *testing.T) {
tests := []struct {
name string
short string
long string
want string
}{
{"dc only marker", "Manage tasks (DC only)", "Create tasks.", "*(DC)*"},
{"cloud only marker", "List issues (Cloud only)", "List issues.", "*(Cloud)*"},
{"no marker", "List repos", "List all repositories.", ""},
{"dc in long but not short", "Show status", "Data Center only.", ""},
{"cloud in long but not short", "Show pipeline", "Bitbucket Cloud only.", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test", Short: tt.short, Long: tt.long}
got := platformTag(cmd)
if got != tt.want {
t.Errorf("platformTag() = %q, want %q", got, tt.want)
}
})
}
}
func TestPlatformTagInSubcommandTable(t *testing.T) {
root := newTestTree()
pr, _, _ := root.Find([]string{"pr"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", pr, true)
got := buf.String()
// DC-only subcommand should have *(DC)* badge (without redundant "(DC only)")
if !strings.Contains(got, "Manage comment reactions *(DC)*") {
t.Error("missing *(DC)* tag for reaction in subcommand table")
}
if strings.Contains(got, "(DC only) *(DC)*") {
t.Error("platform suffix should be stripped to avoid redundancy with badge")
}
// Cloud-only subcommand should have *(Cloud)* badge
if !strings.Contains(got, "Show pipeline status for a PR *(Cloud)*") {
t.Error("missing *(Cloud)* tag for pipeline in subcommand table")
}
// Cross-platform subcommand should NOT have a tag
if strings.Contains(got, "List pull requests *(") {
t.Error("cross-platform command should not have a platform tag")
}
}
func TestOutputOutsideSkillDir(t *testing.T) {
root := newTestTree()
outDir := filepath.Join(t.TempDir(), "rules")
// No SKILL.md in parent — should succeed without error
if err := GenerateAll(root, "bkt", outDir); err != nil {
t.Fatalf("GenerateAll should succeed without SKILL.md: %v", err)
}
// Rule files should still be generated
if _, err := os.Stat(filepath.Join(outDir, "pr.md")); err != nil {
t.Error("expected pr.md even without SKILL.md")
}
}
func TestGroupExamples(t *testing.T) {
// Top-level group with Example
group := &cobra.Command{
Use: "branch",
Short: "Manage branches",
Example: ` # List branches
bkt branch list`,
}
group.AddCommand(&cobra.Command{Use: "list", Short: "List branches"})
var buf strings.Builder
writeGroupFile(&buf, "bkt", group, true)
got := buf.String()
if !strings.Contains(got, "bkt branch list") {
t.Error("group command Example should be rendered")
}
}
func TestNestedGroupExamples(t *testing.T) {
root := newTestTree()
// Add Example to the nested task group in the test tree
pr, _, _ := root.Find([]string{"pr"})
for _, sub := range pr.Commands() {
if sub.Name() == "task" {
sub.Example = ` # List tasks on PR 42
bkt pr task list 42`
}
}
var buf strings.Builder
writeGroupFile(&buf, "bkt", pr, true)
got := buf.String()
if !strings.Contains(got, "bkt pr task list 42") {
t.Error("nested group Example should be rendered")
}
}
func TestFlagsHintWithInheritedOnly(t *testing.T) {
root := &cobra.Command{Use: "bkt"}
root.PersistentFlags().Bool("json", false, "JSON output")
leaf := &cobra.Command{Use: "status", Short: "Show status"}
root.AddCommand(leaf)
got := formatUseLine("bkt", leaf)
if !strings.Contains(got, "[flags]") {
t.Errorf("expected [flags] for inherited-only command, got %q", got)
}
}
func TestTrailingSlashOutDir(t *testing.T) {
root := newTestTree()
skillDir := t.TempDir()
outDir := filepath.Join(skillDir, "rules") + "/" // trailing slash
writeTestFile(t, filepath.Join(skillDir, "SKILL.md"), "# Test\n\n## References\n\n- old\n")
if err := GenerateAll(root, "bkt", outDir); err != nil {
t.Fatalf("GenerateAll with trailing slash: %v", err)
}
// SKILL.md should still be updated despite trailing slash
content, _ := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if !strings.Contains(string(content), "[pr](rules/pr.md)") {
t.Error("SKILL.md not updated when outDir has trailing slash")
}
}
func TestCustomOutDirName(t *testing.T) {
root := newTestTree()
skillDir := t.TempDir()
outDir := filepath.Join(skillDir, "generated")
writeTestFile(t, filepath.Join(skillDir, "SKILL.md"), "# Test\n\n## References\n\n- old\n")
if err := GenerateAll(root, "bkt", outDir); err != nil {
t.Fatalf("GenerateAll with custom dir: %v", err)
}
// Links should use "generated/" not "rules/"
content, _ := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if !strings.Contains(string(content), "[pr](generated/pr.md)") {
t.Error("SKILL.md links should use actual directory name 'generated'")
}
if strings.Contains(string(content), "rules/pr.md") {
t.Error("SKILL.md should not contain hardcoded 'rules/' path")
}
}
func TestStaleFilesRemoved(t *testing.T) {
root := newTestTree()
skillDir := t.TempDir()
outDir := filepath.Join(skillDir, "rules")
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(skillDir, "SKILL.md"), "# Test\n\n## References\n\n- old\n")
// Plant a stale generated .md file (has auto-generated header)
staleFile := filepath.Join(outDir, "old-command.md")
writeTestFile(t, staleFile, "<!-- auto-generated by cmd/docgen — do not edit -->\n\n# stale")
// Plant a hand-maintained .md file (no auto-generated header)
manualFile := filepath.Join(outDir, "NOTES.md")
writeTestFile(t, manualFile, "# Hand-maintained notes\n")
if err := GenerateAll(root, "bkt", outDir); err != nil {
t.Fatalf("GenerateAll: %v", err)
}
// Stale generated file should be gone
if _, err := os.Stat(staleFile); !os.IsNotExist(err) {
t.Error("stale old-command.md should have been removed")
}
// Hand-maintained file should be preserved
if _, err := os.Stat(manualFile); err != nil {
t.Error("hand-maintained NOTES.md should be preserved")
}
// Fresh files should exist
if _, err := os.Stat(filepath.Join(outDir, "pr.md")); err != nil {
t.Error("pr.md should still be generated")
}
}
func TestLongFallbackToShort(t *testing.T) {
cmd := &cobra.Command{
Use: "simple",
Short: "A simple command",
}
var buf strings.Builder
writeGroupFile(&buf, "bkt", cmd, true)
got := buf.String()
if !strings.Contains(got, "A simple command") {
t.Error("should fall back to Short when Long is empty")
}
}
func TestUpdateSkillReferences(t *testing.T) {
dir := t.TempDir()
skillPath := filepath.Join(dir, "SKILL.md")
original := `---
name: bkt
---
# Bitbucket CLI
Some content here.
## References
- **Full command reference**: See [references/commands.md](references/commands.md)
`
writeTestFile(t, skillPath, original)
rules := []ruleEntry{
{File: "pr.md", Label: "pr", Desc: "Manage pull requests"},
{File: "repo.md", Label: "repo", Desc: "Manage repositories"},
{File: "other.md", Label: "other", Desc: "api"},
}
if err := updateSkillReferences(skillPath, rules, "rules"); err != nil {
t.Fatalf("updateSkillReferences: %v", err)
}
content, _ := os.ReadFile(skillPath)
got := string(content)
// Hand-maintained content preserved
if !strings.Contains(got, "# Bitbucket CLI") {
t.Error("lost hand-maintained content")
}
if !strings.Contains(got, "Some content here.") {
t.Error("lost hand-maintained body")
}
// Old reference removed
if strings.Contains(got, "references/commands.md") {
t.Error("old reference should be replaced")
}
// New references present
if !strings.Contains(got, "[pr](rules/pr.md)") {
t.Error("missing pr rule link")
}
if !strings.Contains(got, "[repo](rules/repo.md)") {
t.Error("missing repo rule link")
}
if !strings.Contains(got, "[other](rules/other.md)") {
t.Error("missing other rule link")
}
// Auto-generated markers
if !strings.Contains(got, "auto-generated by cmd/docgen") {
t.Error("missing auto-generated start marker")
}
if !strings.Contains(got, "<!-- end auto-generated -->") {
t.Error("missing auto-generated end marker")
}
}
func TestUpdateSkillReferencesPreservesFooter(t *testing.T) {
dir := t.TempDir()
skillPath := filepath.Join(dir, "SKILL.md")
original := "# CLI\n\n## References\n\n" +
"<!-- auto-generated by cmd/docgen — do not edit below this line -->\n\n" +
"- [pr](rules/pr.md) — old\n\n" +
"<!-- end auto-generated -->\n\n" +
"## Footer\n\nThis should be preserved.\n"
writeTestFile(t, skillPath, original)
rules := []ruleEntry{
{File: "pr.md", Label: "pr", Desc: "Manage pull requests"},
{File: "repo.md", Label: "repo", Desc: "Manage repositories"},
}
if err := updateSkillReferences(skillPath, rules, "rules"); err != nil {
t.Fatalf("updateSkillReferences: %v", err)
}
content, _ := os.ReadFile(skillPath)
got := string(content)
// New references present
if !strings.Contains(got, "[repo](rules/repo.md)") {
t.Error("missing updated repo link")
}
// Old stale reference gone
if strings.Contains(got, "— old") {
t.Error("stale reference should be replaced")
}
// Footer preserved
if !strings.Contains(got, "## Footer") {
t.Error("footer after end marker should be preserved")
}
if !strings.Contains(got, "This should be preserved.") {
t.Error("footer content should be preserved")
}
}
func TestGenerateAll(t *testing.T) {
root := newTestTree()
// GenerateAll expects SKILL.md at filepath.Dir(outDir)/SKILL.md
skillDir := t.TempDir()
outDir := filepath.Join(skillDir, "rules")
writeTestFile(t, filepath.Join(skillDir, "SKILL.md"), "# Test\n\n## References\n\n- old\n")
if err := GenerateAll(root, "bkt", outDir); err != nil {
t.Fatalf("GenerateAll: %v", err)
}
// Check pr.md exists
prFile := filepath.Join(outDir, "pr.md")
if _, err := os.Stat(prFile); err != nil {
t.Fatalf("expected pr.md: %v", err)
}
content, _ := os.ReadFile(prFile)
if !strings.Contains(string(content), "# bkt pr") {
t.Error("pr.md missing title")
}
// Check other.md exists (standalone commands grouped)
otherFile := filepath.Join(outDir, "other.md")
if _, err := os.Stat(otherFile); err != nil {
t.Fatalf("expected other.md: %v", err)
}
content, _ = os.ReadFile(otherFile)
if !strings.Contains(string(content), "# bkt api") {
t.Error("other.md missing api section")
}
// Hidden command should not produce a file
hiddenFile := filepath.Join(outDir, "internal-debug.md")
if _, err := os.Stat(hiddenFile); !os.IsNotExist(err) {
t.Error("hidden command should not produce a file")
}
// SKILL.md updated with rule links
skillContent, _ := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
skillStr := string(skillContent)
if !strings.Contains(skillStr, "[pr](rules/pr.md)") {
t.Error("SKILL.md missing pr rule link")
}
if !strings.Contains(skillStr, "[other](rules/other.md)") {
t.Error("SKILL.md missing other rule link")
}
if strings.Contains(skillStr, "- old") {
t.Error("SKILL.md should have replaced old references")
}
}