diff --git a/cmd/gh-aw/command_groups_test.go b/cmd/gh-aw/command_groups_test.go index e08336b18d8..0d3a3963e9d 100644 --- a/cmd/gh-aw/command_groups_test.go +++ b/cmd/gh-aw/command_groups_test.go @@ -34,6 +34,7 @@ func TestCommandGroupAssignments(t *testing.T) { {name: "validate command in development group", commandName: "validate", expectedGroup: "development", shouldHaveGroup: true}, {name: "mcp command in development group", commandName: "mcp", expectedGroup: "development", shouldHaveGroup: true}, {name: "fix command in development group", commandName: "fix", expectedGroup: "development", shouldHaveGroup: true}, + {name: "format command in development group", commandName: "format", expectedGroup: "development", shouldHaveGroup: true}, {name: "domains command in development group", commandName: "domains", expectedGroup: "development", shouldHaveGroup: true}, // Execution Commands diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 739f2ca6623..2bb342ebec5 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -557,10 +557,10 @@ func runCompileCmd(cmd *cobra.Command, args []string) error { } type commandSet struct { - addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command - mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command - fixCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command - domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd *cobra.Command + addCmd, addWizardCmd, editCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command + mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command + fixCmd, formatCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command + domainsCmd, experimentsCmd, forecastCmd, gradersCmd, modelsCmd, envCmd, jsonSchemaCmd *cobra.Command } func fixPathForCommand(s string) string { @@ -729,6 +729,7 @@ func createCommandSet() commandSet { prCmd: cli.NewPRCommand(), secretsCmd: cli.NewSecretsCommand(), fixCmd: cli.NewFixCommand(), + formatCmd: cli.NewFormatCommand(), upgradeCmd: cli.NewUpgradeCommand(validateEngine), completionCmd: cli.NewCompletionCommand(), hashCmd: cli.NewHashCommand(), @@ -855,7 +856,7 @@ func assignCommandGroups(cmds commandSet) { removeCmd.GroupID, cmds.editCmd.GroupID, cmds.updateCmd.GroupID, cmds.deployCmd.GroupID, cmds.upgradeCmd.GroupID = "setup", "setup", "setup", "setup", "setup" cmds.secretsCmd.GroupID, cmds.envCmd.GroupID, cmds.doctorCmd.GroupID = "setup", "setup", "setup" compileCmd.GroupID, cmds.validateCmd.GroupID, cmds.lintCmd.GroupID = "development", "development", "development" - cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development" + cmds.mcpCmd.GroupID, cmds.fixCmd.GroupID, cmds.formatCmd.GroupID, cmds.domainsCmd.GroupID = "development", "development", "development", "development" runCmd.GroupID, enableCmd.GroupID, disableCmd.GroupID, cmds.trialCmd.GroupID = "execution", "execution", "execution", "execution" cmds.logsCmd.GroupID, cmds.auditCmd.GroupID, cmds.viewCmd.GroupID = "analysis", "analysis", "analysis" cmds.healthCmd.GroupID, cmds.outcomesCmd.GroupID, cmds.checksCmd.GroupID = "analysis", "analysis", "analysis" @@ -870,7 +871,7 @@ func addCommandsToRoot(cmds commandSet) { compileCmd, cmds.addCmd, cmds.addWizardCmd, cmds.editCmd, cmds.updateCmd, cmds.deployCmd, cmds.upgradeCmd, cmds.trialCmd, newCmd, cmds.initCmd, runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.viewCmd, cmds.healthCmd, cmds.outcomesCmd, cmds.checksCmd, cmds.mcpCmd, cmds.mcpServerCmd, cmds.prCmd, versionCmd, cmds.secretsCmd, - cmds.fixCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd, + cmds.fixCmd, cmds.formatCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd, cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.gradersCmd, cmds.modelsCmd, cmds.envCmd, cmds.jsonSchemaCmd, ) } diff --git a/docs/adr/59971-add-frontmatter-format-command.md b/docs/adr/59971-add-frontmatter-format-command.md new file mode 100644 index 00000000000..76cf5d8a07f --- /dev/null +++ b/docs/adr/59971-add-frontmatter-format-command.md @@ -0,0 +1,50 @@ +# ADR-59971: Add Frontmatter Format Command + +**Date**: 2026-09-10 +**Status**: Draft +**Deciders**: gh-aw maintainers + +--- + +### Context + +`gh-aw` workflows store behavior in YAML frontmatter embedded in Markdown files, and this PR adds a CLI path that rewrites that frontmatter. The diff shows repeated requirements to preserve Markdown body content and comments, while also normalizing indentation, key naming via existing codemods, and deterministic field ordering. It also adds support for formatting individual workflows, all workflows in the default directory, and files under a custom directory. The architectural question is how workflow frontmatter normalization should be implemented without introducing a separate configuration format or breaking user-authored comments. + +### Decision + +We will add a dedicated `gh aw format` command that applies the existing codemod pipeline and then rewrites workflow frontmatter through a YAML AST-based normalizer while preserving comments and the Markdown body. We decided to integrate formatting into the CLI command set and operate directly on workflow Markdown files because the PR evidence shows the desired behavior is repository-local, repeatable, and aligned with other `gh aw` authoring commands. This approach makes frontmatter canonicalization an explicit authoring workflow instead of an incidental side effect of compile or validation paths. + +### Alternatives Considered + +#### Alternative 1: Reformat frontmatter implicitly during compile or validation + +The project could normalize workflow frontmatter whenever users run compile, validate, or related commands. This was considered because those commands already parse workflows and would have access to the same frontmatter data. It was not chosen because the diff introduces a standalone `format` command and targeted CLI tests, indicating a need for an explicit write operation that users can run on demand without coupling source-file mutation to read-oriented commands. + +#### Alternative 2: Implement formatting with text-based string rewriting only + +Another option would be to format the frontmatter with line-oriented replacements or regex-style transforms and avoid YAML AST handling. This was considered because it could be simpler for a narrow subset of field migrations. It was not chosen because the implementation and tests emphasize preserving comments, handling nested mappings, and avoiding false delimiters inside block scalars, which are stronger fits for structured YAML node processing than plain text rewriting. + +#### Alternative 3: Leave frontmatter style unmanaged and rely only on codemods + +The team could keep workflow syntax migrations limited to existing codemods and avoid introducing a canonical formatting pass. This was considered because it minimizes new CLI surface area and avoids file rewrite logic. It was not chosen because the PR explicitly adds deterministic ordering, indentation normalization, and round-trip coverage, showing that consistent formatting is now treated as part of workflow maintainability rather than an optional manual convention. + +### Consequences + +#### Positive +- Workflow authors get a dedicated command to canonicalize frontmatter structure across one file, many files, or custom workflow directories. +- Existing codemods can be reused before formatting, allowing syntax migrations and canonical layout to happen in a single operation. +- YAML-node-based normalization can preserve comments and Markdown content while still enforcing deterministic ordering and indentation. + +#### Negative +- The CLI surface area grows, adding another authoring command that maintainers must document, test, and support. +- Formatting now depends on careful frontmatter parsing and YAML encoding behavior, which can introduce edge cases around delimiters, block scalars, or comment retention. +- Rewriting files in place may create larger diffs for users when canonical ordering changes multiple fields at once. + +#### Neutral +- The command is grouped with other development-oriented CLI commands rather than changing execution behavior. +- Test coverage now includes comment preservation, round-tripping, block scalar handling, field ordering, and command registration. +- The formatter works on Markdown workflow files directly and does not introduce a new persisted intermediate representation. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/format_command.go b/pkg/cli/format_command.go new file mode 100644 index 00000000000..1e52f26cf57 --- /dev/null +++ b/pkg/cli/format_command.go @@ -0,0 +1,317 @@ +package cli + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var formatLog = logger.New("cli:format_command") + +// Re-encoding reaches a fixed point after YAML node styles and comments are normalized. +const maxYAMLFormattingPasses = 10 + +// FormatConfig contains configuration for the format command. +type FormatConfig struct { + WorkflowIDs []string + WorkflowDir string + Verbose bool +} + +// RunFormat formats workflow frontmatter using the given configuration. +func RunFormat(config FormatConfig) error { + return runFormatCommand(config.WorkflowIDs, config.WorkflowDir, config.Verbose) +} + +// NewFormatCommand creates the format command. +func NewFormatCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "format [workflow]...", + Short: "Apply codemods and normalize agentic workflow frontmatter", + Long: `Apply all available codemods and normalize YAML frontmatter in agentic workflow files. + +Formatting uses two-space indentation and deterministic field ordering while retaining comments +and Markdown content. If no workflows are specified, all Markdown files in .github/workflows are +formatted. + +` + WorkflowIDExplanation, + Example: ` ` + string(constants.CLIExtensionPrefix) + ` format + ` + string(constants.CLIExtensionPrefix) + ` format my-workflow + ` + string(constants.CLIExtensionPrefix) + ` format --dir custom/workflows`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowDir, _ := cmd.Flags().GetString("dir") + verbose, _ := cmd.Flags().GetBool("verbose") + return runFormatCommand(args, workflowDir, verbose) + }, + } + + cmd.Flags().StringP("dir", "d", "", "Workflow directory (default: $GH_AW_WORKFLOWS_DIR or .github/workflows)") + cmd.ValidArgsFunction = CompleteWorkflowNames + RegisterDirFlagCompletion(cmd, "dir") + return cmd +} + +func runFormatCommand(workflowIDs []string, workflowDir string, verbose bool) error { + formatLog.Printf("Formatting workflows: workflowIDs=%v, workflowDir=%s", workflowIDs, workflowDir) + files, err := resolveFormatWorkflowFiles(workflowIDs, workflowDir, verbose) + if err != nil { + return err + } + if len(files) == 0 { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No workflow files found.")) + return nil + } + + codemods := GetAllCodemods() + for _, file := range files { + if err := formatWorkflowFile(file, codemods, verbose); err != nil { + return fmt.Errorf("failed to format %s: %w", filepath.Base(file), err) + } + } + return nil +} + +func resolveFormatWorkflowFiles(workflowIDs []string, workflowDir string, verbose bool) ([]string, error) { + if workflowDir == "" { + workflowDir = constants.GetWorkflowDir() + } else { + workflowDir = filepath.Clean(workflowDir) + } + + if len(workflowIDs) == 0 { + files, err := getMarkdownWorkflowFiles(workflowDir) + if err != nil { + return nil, err + } + return filterMarkdownFilesWithFrontmatter(files) + } + + files := make([]string, 0, len(workflowIDs)) + for _, workflowID := range workflowIDs { + file, err := resolveWorkflowFileInDir(workflowID, verbose, workflowDir) + if err != nil { + return nil, err + } + files = append(files, file) + } + return files, nil +} + +func formatWorkflowFile(filePath string, codemods []Codemod, verbose bool) error { + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + formatted, appliedCodemods, err := formatWorkflowContentWithInfo(string(content), filePath, codemods) + if err != nil { + return err + } + if formatted == string(content) { + console.LogVerbose(verbose, filepath.Base(filePath)+" is already formatted") + return nil + } + + if err := os.WriteFile(filePath, []byte(formatted), constants.FilePermSensitive); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + if err := scaffoldSerenaSharedWorkflowIfNeeded(filePath, appliedCodemods, formatted, verbose); err != nil { + return fmt.Errorf("failed to scaffold shared Serena workflow: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(filepath.Base(filePath))) + return nil +} + +func formatWorkflowContent(content, filePath string, codemods []Codemod) (string, error) { + formatted, _, err := formatWorkflowContentWithInfo(content, filePath, codemods) + return formatted, err +} + +func formatWorkflowContentWithInfo(content, filePath string, codemods []Codemod) (string, []string, error) { + fixed, appliedCodemods, err := applyFormatCodemods(content, filePath, codemods) + if err != nil { + return "", nil, err + } + formatted, err := normalizeFrontmatter(fixed) + return formatted, appliedCodemods, err +} + +func applyFormatCodemods(content, filePath string, codemods []Codemod) (string, []string, error) { + currentContent := content + var appliedCodemods []string + + for _, codemod := range codemods { + currentResult, err := parser.ExtractFrontmatterFromContent(currentContent) + if err != nil { + return "", nil, err + } + + var newContent string + var applied bool + if codemod.ApplyWithContext != nil { + newContent, applied, err = codemod.ApplyWithContext(currentContent, currentResult.Frontmatter, filePath) + } else { + newContent, applied, err = codemod.Apply(currentContent, currentResult.Frontmatter) + } + if err != nil { + wrappedErr := fmt.Errorf("codemod %s failed: %w", codemod.ID, err) + if codemod.Guided { + return "", nil, &GuidedError{Cause: wrappedErr} + } + return "", nil, wrappedErr + } + if applied { + currentContent = newContent + appliedCodemods = append(appliedCodemods, codemod.Name) + } + } + + return currentContent, appliedCodemods, nil +} + +func normalizeFrontmatter(content string) (string, error) { + frontmatter, suffix, err := splitFrontmatterForFormatting(content) + if err != nil { + return "", err + } + if strings.TrimSpace(frontmatter) == "" { + return "---\n---" + suffix, nil + } + + var document yaml.Node + decoder := yaml.NewDecoder(strings.NewReader(frontmatter)) + if err := decoder.Decode(&document); err != nil { + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + var extra yaml.Node + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return "", errors.New("frontmatter must contain a single YAML document") + } + return "", fmt.Errorf("failed to parse frontmatter: %w", err) + } + + if len(document.Content) == 0 { + return "---\n---" + suffix, nil + } + root := document.Content[0] + if root.Kind != yaml.MappingNode { + return "", errors.New("frontmatter must be a YAML mapping") + } + orderYAMLMapping(root, constants.PriorityWorkflowFields) + + formattedYAML := "" + for range maxYAMLFormattingPasses { + next, err := encodeYAMLDocument(&document) + if err != nil { + return "", err + } + if next == formattedYAML { + return "---\n" + formattedYAML + "---" + suffix, nil + } + formattedYAML = next + var normalizedDocument yaml.Node + if err := yaml.Unmarshal([]byte(formattedYAML), &normalizedDocument); err != nil { + return "", fmt.Errorf("failed to parse encoded frontmatter: %w", err) + } + document = normalizedDocument + } + return "", fmt.Errorf("frontmatter formatting did not stabilize after %d passes", maxYAMLFormattingPasses) +} + +func encodeYAMLDocument(document *yaml.Node) (string, error) { + var output bytes.Buffer + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return "", fmt.Errorf("failed to encode frontmatter: %w", err) + } + if err := encoder.Close(); err != nil { + return "", fmt.Errorf("failed to encode frontmatter: %w", err) + } + + formattedYAML := output.String() + formattedYAML = strings.TrimPrefix(formattedYAML, "---\n") + formattedYAML = strings.TrimSuffix(formattedYAML, "...\n") + if formattedYAML != "" && !strings.HasSuffix(formattedYAML, "\n") { + formattedYAML += "\n" + } + return formattedYAML, nil +} + +func splitFrontmatterForFormatting(content string) (string, string, error) { + firstNewline := strings.IndexByte(content, '\n') + if firstNewline < 0 || strings.TrimSpace(content[:firstNewline]) != "---" { + return "", "", errors.New("workflow does not contain YAML frontmatter") + } + + for start := firstNewline + 1; start <= len(content); { + end := strings.IndexByte(content[start:], '\n') + if end < 0 { + end = len(content) + } else { + end += start + } + line := strings.TrimSuffix(content[start:end], "\r") + if line == "---" { + return content[firstNewline+1 : start], content[end:], nil + } + if end == len(content) { + break + } + start = end + 1 + } + return "", "", errors.New("frontmatter not properly closed") +} + +func orderYAMLMapping(node *yaml.Node, priorityFields []string) { + if node.Kind == yaml.MappingNode { + type pair struct { + key *yaml.Node + value *yaml.Node + } + pairs := make([]pair, 0, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + pairs = append(pairs, pair{key: node.Content[i], value: node.Content[i+1]}) + } + slices.SortStableFunc(pairs, func(a, b pair) int { + return compareYAMLKeys(a.key.Value, b.key.Value, priorityFields) + }) + node.Content = node.Content[:0] + for _, item := range pairs { + node.Content = append(node.Content, item.key, item.value) + } + } + + for _, child := range node.Content { + orderYAMLMapping(child, nil) + } +} + +func compareYAMLKeys(a, b string, priorityFields []string) int { + if len(priorityFields) > 0 { + aIndex := slices.Index(priorityFields, a) + bIndex := slices.Index(priorityFields, b) + switch { + case aIndex >= 0 && bIndex >= 0: + return aIndex - bIndex + case aIndex >= 0: + return -1 + case bIndex >= 0: + return 1 + } + } + return strings.Compare(a, b) +} diff --git a/pkg/cli/format_command_fuzz_test.go b/pkg/cli/format_command_fuzz_test.go new file mode 100644 index 00000000000..a60fa7da65b --- /dev/null +++ b/pkg/cli/format_command_fuzz_test.go @@ -0,0 +1,69 @@ +//go:build !integration + +package cli + +import ( + "strings" + "testing" +) + +func FuzzNormalizeFrontmatter(f *testing.F) { + f.Add("---\nengine: copilot\non: workflow_dispatch\n---\n# Body") + f.Add("---\ndescription: |\n before\n ---\n after\n---\nMarkdown") + f.Add("---\n000000000: |\n#00\n---") + f.Add("---\n0: |\n\n\n 00\n---") + f.Add("---\ninvalid: [\n---") + f.Add("# No frontmatter") + + f.Fuzz(func(t *testing.T, content string) { + formatted, err := normalizeFrontmatter(content) + if err != nil { + return + } + + _, originalSuffix, err := splitFrontmatterForFormatting(content) + if err != nil { + t.Fatalf("successfully formatted content could not be split: %v", err) + } + _, formattedSuffix, err := splitFrontmatterForFormatting(formatted) + if err != nil { + t.Fatalf("formatted content could not be split: %v", err) + } + if formattedSuffix != originalSuffix { + t.Fatalf("Markdown content changed during formatting") + } + + reformatted, err := normalizeFrontmatter(formatted) + if err != nil { + t.Fatalf("formatted content could not be formatted again: %v", err) + } + if reformatted != formatted { + t.Fatalf("formatting is not idempotent:\nfirst: %q\nsecond: %q", formatted, reformatted) + } + }) +} + +func FuzzSplitFrontmatterForFormatting(f *testing.F) { + f.Add("engine: copilot\n", "\n# Body") + f.Add("description: |\n before\n ---\n after\n", "\nMarkdown") + f.Add("", "") + + f.Fuzz(func(t *testing.T, frontmatter, suffix string) { + frontmatter = strings.ReplaceAll(frontmatter, "\r", "") + if frontmatter != "" && !strings.HasSuffix(frontmatter, "\n") { + frontmatter += "\n" + } + content := "---\n" + frontmatter + "---\n" + suffix + extracted, extractedSuffix, err := splitFrontmatterForFormatting(content) + if err != nil { + t.Fatalf("constructed frontmatter could not be split: %v", err) + } + reconstructed := "---\n" + extracted + "---" + extractedSuffix + if reconstructed != content { + t.Fatalf("split content did not reconstruct input") + } + if !strings.HasPrefix(reconstructed, "---\n") { + t.Fatalf("reconstructed content lost opening delimiter") + } + }) +} diff --git a/pkg/cli/format_command_test.go b/pkg/cli/format_command_test.go new file mode 100644 index 00000000000..ccbb8c5b9ec --- /dev/null +++ b/pkg/cli/format_command_test.go @@ -0,0 +1,185 @@ +//go:build !integration + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatWorkflowContentAppliesCodemodsAndPreservesComments(t *testing.T) { + t.Parallel() + input := `--- +timeout_minutes: 30 # migrate this +engine: + model: test + id: copilot +# Trigger comment +on: + workflow_dispatch: +permissions: + issues: read + contents: read +--- + +# Workflow + +Keep this Markdown exactly. +` + + formatted, err := formatWorkflowContent(input, "workflow.md", GetAllCodemods()) + require.NoError(t, err) + + assert.NotContains(t, formatted, "timeout_minutes:") + assert.Contains(t, formatted, "timeout-minutes: 30 # migrate this") + assert.Contains(t, formatted, "# Trigger comment") + assert.Contains(t, formatted, " workflow_dispatch:") + assert.Less(t, strings.Index(formatted, "on:"), strings.Index(formatted, "permissions:")) + assert.Less(t, strings.Index(formatted, "permissions:"), strings.Index(formatted, "engine:")) + assert.Less(t, strings.Index(formatted, " contents:"), strings.Index(formatted, " issues:")) + assert.Contains(t, formatted, "# Workflow\n\nKeep this Markdown exactly.") +} + +func TestFormatWorkflowContentRoundTrips(t *testing.T) { + t.Parallel() + tests := map[string]string{ + "nested lists and inline comment": `--- +tools: + web-fetch: + allowed-domains: + - example.com +on: + workflow_dispatch: +engine: copilot # engine comment +--- +Body with no trailing newline`, + "standalone and nested comments": `--- +# tools comment +tools: + web-fetch: + # domain comment + allowed-domains: [example.com] +engine: copilot +--- + +# Body +`, + } + + for name, input := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + once, err := formatWorkflowContent(input, "workflow.md", nil) + require.NoError(t, err) + twice, err := formatWorkflowContent(once, "workflow.md", nil) + require.NoError(t, err) + + assert.Equal(t, once, twice) + assert.Contains(t, once, "comment") + }) + } +} + +func TestNormalizeFrontmatterDoesNotTreatBlockScalarAsDelimiter(t *testing.T) { + t.Parallel() + input := `--- +description: | + before + --- + after +engine: copilot +--- +# Body` + + formatted, err := normalizeFrontmatter(input) + require.NoError(t, err) + + assert.Contains(t, formatted, " ---") + assert.Contains(t, formatted, " after") + assert.Contains(t, formatted, "engine: copilot") + assert.True(t, strings.HasSuffix(formatted, "\n# Body")) +} + +func TestNormalizeFrontmatterUsesLogicalThenAlphabeticalFieldOrder(t *testing.T) { + t.Parallel() + input := `--- +zzz: last +steps: [] +bbb: middle +on: workflow_dispatch +aaa: first +permissions: read-all +network: defaults +--- +` + + formatted, err := normalizeFrontmatter(input) + require.NoError(t, err) + + expectedOrder := []string{ + "on:", + "permissions:", + "network:", + "steps:", + "aaa:", + "bbb:", + "zzz:", + } + previous := -1 + for _, field := range expectedOrder { + index := strings.Index(formatted, field) + require.Greater(t, index, previous, "%s was not in logical/alphabetical order:\n%s", field, formatted) + previous = index + } +} + +func TestRunFormatFormatsSelectedWorkflow(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + workflowPath := filepath.Join(tempDir, "example.md") + input := "---\nengine: copilot\non:\n workflow_dispatch:\n---\n# Body" + require.NoError(t, os.WriteFile(workflowPath, []byte(input), 0o644)) + + err := RunFormat(FormatConfig{ + WorkflowIDs: []string{workflowPath}, + WorkflowDir: tempDir, + }) + require.NoError(t, err) + + formatted, err := os.ReadFile(workflowPath) + require.NoError(t, err) + assert.Equal(t, "---\non:\n workflow_dispatch:\nengine: copilot\n---\n# Body", string(formatted)) +} + +func TestRunFormatOnlyFormatsAgenticWorkflowsInDirectory(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + workflowPath := filepath.Join(tempDir, "workflow.md") + documentationPath := filepath.Join(tempDir, "documentation.md") + require.NoError(t, os.WriteFile(workflowPath, []byte("---\nengine: copilot\non: push\n---\n# Body"), 0o644)) + require.NoError(t, os.WriteFile(documentationPath, []byte("# Documentation\n"), 0o644)) + + err := RunFormat(FormatConfig{WorkflowDir: tempDir}) + require.NoError(t, err) + + formatted, err := os.ReadFile(workflowPath) + require.NoError(t, err) + assert.Equal(t, "---\non: push\nengine: copilot\n---\n# Body", string(formatted)) + documentation, err := os.ReadFile(documentationPath) + require.NoError(t, err) + assert.Equal(t, "# Documentation\n", string(documentation)) +} + +func TestNewFormatCommand(t *testing.T) { + t.Parallel() + cmd := NewFormatCommand() + + assert.Equal(t, "format", cmd.Name()) + assert.NotNil(t, cmd.Flags().Lookup("dir")) + assert.NotNil(t, cmd.ValidArgsFunction) +}