mirror of
https://github.com/avivsinai/bitbucket-cli.git
synced 2026-09-19 07:13:00 +08:00
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:
@@ -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,)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user